Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5bea892
chore(shared): 更新 Twitter 归档与 JWT 共享契约
xiaoland Oct 19, 2026
97a61da
feat(twitter): 连接并阅读 x-monkey 原生归档
xiaoland Oct 19, 2026
24e0964
fix(tests): 按 Registry 实际路径识别 Twitter MF
xiaoland Oct 19, 2026
8011efb
fix(tests): 验证 Twitter 索引找回与按需媒体读取
xiaoland Oct 19, 2026
0025cc5
fix(twitter): 在设置各步骤显示归档连接入口
xiaoland Oct 19, 2026
d2be681
fix(tests): 为预览域名授予限定来源的网络权限
xiaoland Oct 19, 2026
2cf645a
fix(twitter): 展开媒体时刷新完整内容关系
xiaoland Oct 19, 2026
2583e6b
docs(shared): 同步 Twitter 媒体按需读取合同
xiaoland Oct 19, 2026
335b131
fix(twitter): 仅在展开时读取原生媒体地址
xiaoland Oct 19, 2026
a26e164
docs(twitter): 说明仅有媒体元数据时的读取边界
xiaoland Oct 19, 2026
8cdf51b
chore(tests): 格式化 Twitter 验收截图调用
xiaoland Oct 19, 2026
94aa94e
fix(tests): 修正 Twitter 搜索验收流程
xiaoland Oct 19, 2026
1d4e53b
chore(twitter): 将候选验收移出正式测试集
xiaoland Oct 19, 2026
426697e
docs(shared): 同步 Twitter 模型与浏览器交接合同
xiaoland Oct 20, 2026
c78007a
ref(twitter): 将媒体读取与凭据交接归还基础设施
xiaoland Oct 20, 2026
48a9280
fix(preview): 从确切提交生成配套 Host 与 MF 版本
xiaoland Oct 20, 2026
a86e617
fix(ci): 使用配套版本验证原生 Twitter MF
xiaoland Oct 20, 2026
b844fff
docs(shared): 同步 Core 自托管连接合同
xiaoland Oct 20, 2026
8f6f09b
ref(twitter): 使用 Core 自托管连接并撤回通用交接
xiaoland Oct 20, 2026
40f5849
fix(scripts): 正确读取连接请求的完整头
xiaoland Oct 20, 2026
3eb59df
docs(shared): 引用已合并的 Twitter 归档合同
xiaoland Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/bright-foxes-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@inkcre/core': patch
'@inkcre/ext-twitter': minor
---

普通 Web Peer JWT 保持 24 小时签发默认值,并与数据库准入策略解耦。Twitter 新增 x-monkey 原生归档记录的读取与浏览器连接入口;账户页在本地签发短时 Peer JWT,不增加 Host SDK API,因此仍兼容 Core SDK >=0.4.0。
8 changes: 5 additions & 3 deletions docs/30-unit-tdd/client-runtime-and-delegation.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,9 @@ Worker, or runtime-config endpoint fallback.
The signing secret is masked during ordinary display, excluded from logs, and retained only in
browser-owned runtime state. The explicit full-browser export includes the secret so importing it can
restore the same connection, identity, and local preferences in another browser. Signed JWTs are memory-only. The auth store derives algorithm,
issuer, audience, role, and maximum lifetime from the generated Peer runtime contract, regenerates
the token when the secret changes, and clears it when the secret is removed. PostgREST JSON access,
issuer, audience, and role from the generated Peer runtime contract. Ordinary Web tokens use the
browser signer's 24-hour default lifetime, independently of database admission policy. The store
regenerates the token when the secret changes and clears it when the secret is removed. PostgREST JSON access,
raw byte access, and Peer HTTP delegation reuse this single authentication authority.

Web Peer registration publishes the client-web application version and a best-effort browser/version/system
Expand All @@ -123,7 +124,8 @@ against the original database connection.

The browser signer backdates `iat` by five seconds and derives `exp` from that adjusted value. This
absorbs ordinary sub-second or small deployment clock skew at the authentication boundary without
increasing the shared maximum lifetime or leaking retries into PostgREST/domain callers.
moving retries into PostgREST/domain callers. The lifetime is the browser signer's policy;
database admission does not impose a shared maximum lifetime.

## Browser Background Participation

Expand Down
4 changes: 4 additions & 0 deletions docs/30-unit-tdd/info-base.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,7 @@ Producers emit exact, versioned Resolver IDs; keep protocol/source identity and
in canonical metadata/root Blocks; put actual media or document bytes in a semantic content Block;
and represent associations with Relations. They never encode attachment identity into root
content or choose a semantic Storage type. Storage remains bytes-only mechanics.

只捕获媒体元数据的归档不需要预建持久语义内容 Block。`inkcre/twitter` 保存共同模型的 Media 记录,不建立 Media-to-content Relation;列表、文本投影和索引只读取已有元数据。用户明确展开后,Twitter Resolver 选择图片 URL 或最高码率的可用 MP4,再交给现有 `HttpStorage` 按通用 HTTP(S)、超时、字节上限和重定向规则读取。它不另设 CDN、端口或重定向政策,读取也不持久化媒体字节。

媒体显示使用运行态 Object URL,随 Resolver 刷新、释放和缓存替换撤销。协议 DTO、provider 响应及共同信息模型的转换属于 Twitter 适配器,不能让客户端协议字段集变成 info-base 或 Resolver 的持久数据权威。
18 changes: 18 additions & 0 deletions docs/40-deployment/development-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ which proves the checked client remains compatible with current delivery. A brea
revision requires the coordinated producer-first migration defined by the shared contract.
Pull-request evidence does not establish source-branch or migration equality between repositories.

尚未发布的候选 Core 可用 `pnpm contract:sync --image sha256:<完整的本地镜像 ID>` 生成投影,再用相同参数追加
`--check` 核对。镜像必须已存在于所选 local/ssh Docker daemon,工具核对 ID 并使用同一不可变镜像创建、清理
独占的 typegen 数据库,不接受可移动 tag,也不会拉取本地 ID。此入口不支持 external provider;需选择构建候选的
同一 provider。`contracts/core-release.json` 仍只接纳已发布的 GHCR digest,本地生成不能代替发布产物验收。

## Operator Commands

Run these from the repository root; [`package.json`](../../package.json) owns their exact carriers.
Expand All @@ -126,3 +131,16 @@ pnpm db:reset
pnpm contract:sync
pnpm test:e2e
```

Twitter 与本地 Core 镜像的候选验收保存在 `scripts/tests/`,不加入默认 `check`、`test:e2e` 或 CI 门禁。可独立执行:

```bash
pnpm verify:core-release
pnpm verify:twitter --list
pnpm verify:twitter
```

Twitter 验收需要预先启动且兼容的 Core、PostgREST、Web 和 Twitter MF 产物,并设置
`INKCRE_E2E_CORE_URL`、`INKCRE_E2E_POSTGREST_URL`、`INKCRE_E2E_WEB_URL`、
`INKCRE_E2E_JWT_SECRET`。它会安装或启用浏览器 Peer 的 Twitter 扩展、写入测试数据并提交词法索引 Job,
不会自动清理数据;应使用独立验收信息库。截图保存在 `test-results/twitter-acceptance/`。
12 changes: 12 additions & 0 deletions docs/40-deployment/native-extension-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ The Web workflow does not read Core source or a Core version plan. Python public
selects nor waits for MF, and MF publication neither selects nor waits for Python. Host SDK ranges
remain native loading requirements; they do not describe cross-distribution business compatibility.

Pages pull-request previews are the deliberate exception to building directly from pending package
versions: the trusted preview controller runs the existing Changesets version command in an isolated
copy so the preview can load a mutually compatible Host and MF when a change raises the MF's minimum
Host SDK. This produces temporary derived versions for preview only. It neither consumes the pending
changesets in the PR nor replaces the normal Version PR and protected-main release lifecycle.

The Workspace contract check applies the same preparation after `pnpm check` has validated the
original checkout. It builds the isolated candidate Host and MF before inspecting the native
snapshot, and records the exact checked-out commit SHA in the job summary. For pull requests that
SHA can be GitHub's synthetic merge commit; it identifies the source actually exported by
`git archive`, while static, typed, and package checks still run on the original checkout first.

A plugin owns compatibility of its shared configuration, state, Source/Job parameters and stored
information. Changesets record MF changes even when no Python change is necessary. Published native
coordinates and saved candidate bytes are immutable; repairs require a new native version.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Block, ResolverCache } from '@inkcre/core'
import { InkButton, InkImage } from '@inkcre/ui-web'
import type { MediaRecord } from '../../schema'
import { ArchiveMediaResolver, type ArchiveMediaContentModel } from '../../resolver'

const props = defineProps<{ blockId: number; record: MediaRecord | null }>()
const pending = ref(false)
const opened = ref(false)
const result = ref<ArchiveMediaContentModel | null>(null)
const error = ref<string | null>(null)
const media = computed(() => (props.record && 'type' in props.record ? props.record : null))

async function openMedia(): Promise<void> {
if (pending.value || opened.value) return
pending.value = true
error.value = null
try {
const block = await Block.get(props.blockId)
const resolver = await ResolverCache.getResolver(block)
if (!(resolver instanceof ArchiveMediaResolver))
throw new Error('Media resolver is unavailable.')
result.value = await resolver.loadContent()
opened.value = true
} catch (cause) {
error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
pending.value = false
}
}
</script>

<template>
<article class="archive-media">
<p v-if="!media">Media record {{ record?.id ?? blockId }} is an identity placeholder.</p>
<template v-else>
<p>{{ media.alt || media.title || media.description || `${media.type} · ${media.id}` }}</p>
<InkButton
v-if="!opened"
text="Load media"
theme="subtle"
:is-loading="pending"
:disabled="pending"
@click="openMedia"
/>
<InkImage
v-if="result?.content && media.type === 'photo'"
:src="result.content.objectUrl"
:alt="media.alt || media.title || 'Post image'"
:title="media.title || media.alt || ''"
/>
<video
v-else-if="result?.content"
:src="result.content.objectUrl"
controls
preload="none"
:aria-label="media.alt || media.title || 'Post video'"
/>
<p v-else-if="opened" class="empty">Media bytes are not available; metadata is preserved.</p>
<p v-if="error" class="error" role="alert">{{ error }}</p>
</template>
</article>
</template>

<style scoped>
.archive-media {
display: grid;
align-content: start;
gap: 0.5rem;
min-width: 0;
}
.archive-media p {
margin: 0;
overflow-wrap: anywhere;
}
.archive-media img,
.archive-media video {
width: 100%;
max-height: 30rem;
object-fit: contain;
}
.empty {
color: var(--sys-color-text-subtle);
}
.error {
color: var(--sys-color-feedback-error);
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { SolvedContentRendererProps } from '@inkcre/core'
import type { MediaRecord } from '../../schema'

defineProps<SolvedContentRendererProps<MediaRecord>>()
</script>

<template>
<article class="archive-media-preview">
<template v-if="'type' in solvedContent"
><strong>{{ solvedContent.type }}</strong>
<p>
{{
solvedContent.alt || solvedContent.title || solvedContent.description || solvedContent.id
}}
</p></template
>
<span v-else>Media {{ solvedContent.id }} (identity only)</span>
</article>
</template>

<style scoped>
.archive-media-preview {
display: grid;
gap: 0.3rem;
min-width: 8rem;
max-width: 20rem;
overflow-wrap: anywhere;
}
p {
margin: 0;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<script setup lang="ts">
import { computed } from 'vue'
import { getInfoBaseRouter, type SolvedContentRendererProps } from '@inkcre/core'
import type { ArchivePostContentModel } from '../../resolver'
import RichTextView from './richTextView.vue'
import ArchiveMediaContent from './archiveMediaContent.vue'
import ArticleBlockView from './articleBlockView.vue'

const props = defineProps<SolvedContentRendererProps<ArchivePostContentModel>>()
const router = getInfoBaseRouter()
const post = computed(() => props.solvedContent.post)
const fullPost = computed(() => ('authorId' in post.value ? post.value : null))
const author = computed(() => props.solvedContent.author)
const linked = computed(() =>
props.solvedContent.references.filter(({ reference }) => reference.type !== 'media')
)
const media = computed(() =>
props.solvedContent.references
.filter(({ reference }) => reference.type === 'media')
.sort((left, right) => {
if (left.reference.type !== 'media' || right.reference.type !== 'media') return 0
return left.reference.position - right.reference.position
})
)
function navigate(block: number): void {
void router.push({ name: 'block', block })
}
</script>

<template>
<article class="archive-post">
<header>
<strong v-if="author && 'handle' in author"
>{{ author.name }} <span>@{{ author.handle }}</span></strong
>
<span v-else>Unknown author · {{ fullPost?.authorId || post.id }}</span>
<time v-if="fullPost">{{ fullPost.createdAt }}</time>
<a
v-if="fullPost"
:href="`https://x.com/i/status/${fullPost.id}`"
target="_blank"
rel="noopener noreferrer"
>
Original post · {{ fullPost.id }}
</a>
</header>
<p v-if="fullPost?.contentWarning" class="warning">
{{ fullPost.contentWarning.title }} {{ fullPost.contentWarning.text }}
</p>
<RichTextView v-if="fullPost?.text" :value="fullPost.text" />
<section v-if="fullPost?.article" class="article">
<h2>{{ fullPost.article.title }}</h2>
<p v-if="fullPost.article.preview" class="preview">{{ fullPost.article.preview }}</p>
<ArticleBlockView
v-for="(block, index) in fullPost.article.blocks"
:key="index"
:block="block"
:entities="fullPost.article.entities"
/>
</section>
<blockquote v-if="fullPost?.replyTo?.tombstone">{{ fullPost.replyTo.tombstone }}</blockquote>
<blockquote v-if="fullPost?.quote?.tombstone">{{ fullPost.quote.tombstone }}</blockquote>
<p v-if="fullPost?.replyTo && !fullPost.replyTo.tombstone" class="context">
Reply to @{{ fullPost.replyTo.handle || fullPost.replyTo.userId || 'unknown' }} ·
{{ fullPost.replyTo.postId }}
</p>
<p v-if="fullPost?.quote && !fullPost.quote.tombstone" class="context">
Quoted post · {{ fullPost.quote.postId }}
</p>
<p v-if="fullPost?.repostOf" class="context">Repost · {{ fullPost.repostOf }}</p>
<dl v-if="fullPost?.counts" class="counts">
<template v-for="(value, key) in fullPost.counts" :key="key"
><dt>{{ key }}</dt>
<dd>{{ value }}</dd></template
>
</dl>
<section v-if="media.length" class="media-list" aria-label="Post media">
<ArchiveMediaContent
v-for="item in media"
:key="item.blockId"
:block-id="item.blockId"
:record="item.record"
/>
</section>
<ul v-if="linked.length" class="related-records" aria-label="Related Twitter records">
<li v-for="item in linked" :key="item.blockId">
<button type="button" @click="navigate(item.blockId)">
View {{ item.reference.type }} {{ item.record?.id ?? 'unavailable record' }}
</button>
</li>
</ul>
</article>
</template>

<style scoped>
.archive-post {
display: grid;
gap: 1rem;
max-width: 52rem;
overflow-wrap: anywhere;
}
header {
display: grid;
gap: 0.25rem;
}
header span,
time,
.context,
.counts {
color: var(--sys-color-text-subtle);
}
time,
.context {
font-size: 0.875em;
}
.warning {
padding: 0.6rem;
border: 1px solid currentColor;
}
.article {
border-top: 1px solid var(--sys-color-border-subtle);
padding-top: 1rem;
}
.article p {
white-space: pre-wrap;
}
.preview {
color: var(--sys-color-text-subtle);
}
.counts {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.7rem;
margin: 0;
}
.counts dt {
text-transform: capitalize;
}
.counts dd {
margin: 0 0.8rem 0 0;
}
.related-records {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
margin: 0;
padding-left: 1.25rem;
}
.media-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.75rem;
}
blockquote {
border-left: 3px solid currentColor;
margin: 0;
padding-left: 0.75rem;
}
</style>
Loading
Loading