diff --git a/.changes/+heroku-self-hosting.added.md b/.changes/+heroku-self-hosting.added.md new file mode 100644 index 00000000..d9f922b9 --- /dev/null +++ b/.changes/+heroku-self-hosting.added.md @@ -0,0 +1 @@ +支持通过 GitHub Actions 将自托管实例部署到 Heroku 与 Neon。 diff --git a/.changes/+public-http-sdk.added.md b/.changes/+public-http-sdk.added.md new file mode 100644 index 00000000..38c4e5d9 --- /dev/null +++ b/.changes/+public-http-sdk.added.md @@ -0,0 +1 @@ +Core Host SDK 0.3 提供公共 HTTP 基址读取接口,服务包版本仍由独立发行流程管理。 diff --git a/.changes/+web-extension-install.fixed.md b/.changes/+web-extension-install.fixed.md new file mode 100644 index 00000000..56819315 --- /dev/null +++ b/.changes/+web-extension-install.fixed.md @@ -0,0 +1 @@ +修复 Web 无法通过所选 Core Host 安装 Python-only Extension 的接入缺口,复用既有精确版本安装与兼容性校验,不改变 Extension Host SDK 契约。 diff --git a/.changes/+worktree-ssh-socket.fixed.md b/.changes/+worktree-ssh-socket.fixed.md new file mode 100644 index 00000000..57d293ca --- /dev/null +++ b/.changes/+worktree-ssh-socket.fixed.md @@ -0,0 +1 @@ +修复深层 worktree 路径下开发数据库 SSH control socket 超长导致启动失败的问题,保留实例独立的 tunnel 与清理归属。 diff --git a/.github/workflows/self-host-heroku-neon.yml b/.github/workflows/self-host-heroku-neon.yml new file mode 100644 index 00000000..3018b2f9 --- /dev/null +++ b/.github/workflows/self-host-heroku-neon.yml @@ -0,0 +1,120 @@ +name: Deploy self-hosted InKCre to Heroku + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: self-host-heroku-neon-${{ github.repository }} + cancel-in-progress: false + +env: + CORE_DATABASE_PASSWORD: ${{ secrets.CORE_DATABASE_PASSWORD }} + HEAD_SHA: ${{ github.sha }} + HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} + IMAGE_TAG: inkcre-self-host-schema:${{ github.sha }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + NEON_API_KEY: ${{ secrets.NEON_API_KEY }} + NEON_PROJECT_ID: ${{ vars.NEON_PROJECT_ID }} + POSTGREST_DATABASE_PASSWORD: ${{ secrets.POSTGREST_DATABASE_PASSWORD }} + SOURCE_REVISION: ${{ github.sha }} + +jobs: + deploy: + name: Converge Heroku and Neon deployment + runs-on: ubuntu-latest + services: + postgres: + image: >- + pgvector/pgvector:pg17@sha256:d2ef61f42ef767baa5a1475393303cc235bcd92febd9d7014eddb48b41f3bad0 + env: + POSTGRES_DB: inkcre + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d inkcre" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + steps: + - name: Checkout exact selected commit + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Validate required fork settings + id: settings + env: + HEROKU_APP_PREFIX: ${{ vars.HEROKU_APP_PREFIX }} + run: bash scripts/automation/self_host.sh validate-heroku + + - name: Build exact service and transport images + run: | + bash scripts/automation/runtime_artifact.sh build-schema-source + bash scripts/automation/runtime_contract.sh export + bash scripts/automation/runtime_artifact.sh stage-schema-evidence + bash scripts/automation/runtime_contract.sh restore + docker build --platform linux/amd64 --provenance=false \ + --build-arg "SOURCE_REVISION=$HEAD_SHA" --target service \ + --tag "inkcre-production-web:$HEAD_SHA" . + bash scripts/automation/runtime_artifact.sh build-production-transports + + - name: Install pinned Heroku CLI + run: npm install --global heroku@11.8.1 + + - name: Resolve the Neon default branch owner coordinates + id: neon + run: bash scripts/automation/self_host.sh resolve-neon + + - name: Resolve self-hosted Heroku apps + id: heroku + env: + EXPECTED_DEPLOYMENT_PROFILE: ${{ steps.settings.outputs.deployment_profile }} + HEROKU_APP_NAME: ${{ steps.settings.outputs.app_name }} + POSTGREST_APP_NAME: ${{ steps.settings.outputs.postgrest_app_name }} + run: bash scripts/automation/production_delivery.sh resolve-apps + + - name: Converge the self-hosted database contract + id: database + env: + DATABASE_ENVIRONMENT: runtime + MIGRATION_DATABASE_URL: ${{ steps.neon.outputs.migration_database_url }} + POOLED_OWNER_DATABASE_URL: ${{ steps.neon.outputs.source_database_url }} + run: bash scripts/automation/production_delivery.sh converge-database + + - name: Push and release self-hosted peer processes + id: release + env: + APP_NAME: ${{ steps.heroku.outputs.app_name }} + DATABASE_URL: ${{ steps.database.outputs.core_database_url }} + INKCRE_DEPLOYMENT_PROFILE: ${{ steps.settings.outputs.deployment_profile }} + PEER_NAME: ${{ steps.heroku.outputs.app_name }} + POSTGREST_APP_NAME: ${{ steps.heroku.outputs.postgrest_app_name }} + POSTGREST_DATABASE_URL: ${{ steps.database.outputs.postgrest_database_url }} + run: bash scripts/automation/production_delivery.sh release + + - name: Converge self-hosted Peer advertisement + env: + APP_NAME: ${{ steps.heroku.outputs.app_name }} + DATABASE_URL: ${{ steps.database.outputs.core_database_url }} + WEB_URL: ${{ steps.heroku.outputs.web_url }} + run: bash scripts/automation/production_delivery.sh advertise + + - name: Probe self-hosted peers + env: + APP_NAME: ${{ steps.heroku.outputs.app_name }} + CORE_IMAGE_DIGEST: ${{ steps.release.outputs.core_local_image_id }} + CORE_IMAGE_LABEL: Exact local image ID + CORE_LOCAL_IMAGE_ID: ${{ steps.release.outputs.core_local_image_id }} + DEPLOYMENT_SUMMARY_TITLE: Self-hosted InKCre on Heroku + Neon + POSTGREST_APP_NAME: ${{ steps.heroku.outputs.postgrest_app_name }} + POSTGREST_RELEASE: ${{ steps.release.outputs.postgrest_release }} + POSTGREST_URL: ${{ steps.heroku.outputs.postgrest_url }} + WEB_RELEASE: ${{ steps.release.outputs.web_release }} + WEB_URL: ${{ steps.heroku.outputs.web_url }} + run: bash scripts/automation/production_delivery.sh probe diff --git a/README.md b/README.md index d133685d..05042c8a 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,15 @@ Developer setup and shared-skill notes: [CONTRIBUTING.md](CONTRIBUTING.md) ## Browser-Only Self-Hosting -A repository owner can deploy their own InKCre instance to Neon and two Render Docker -services without cloning this repository. Forking is the onboarding mechanism; the deployed -instance is independent from InKCre's canonical production environment. Configure the six -documented GitHub Secrets/Variables, then run the checked-in `Deploy self-hosted InKCre` -workflow. The JWT signing secret remains private and grants full Peer authority. +A repository owner can deploy their own InKCre instance to Neon and either Render or Heroku +without cloning this repository. Forking is the onboarding mechanism; the deployed instance is +independent from InKCre's canonical production environment. Configure the provider profile's +documented GitHub Secrets/Variables, then run its checked-in self-host workflow. The JWT signing +secret remains private and grants full Peer authority. Exact onboarding steps, runtime limits, and cleanup: -[Self-Hosting On Render And Neon](docs/40-deployment/render-neon-self-host.md). +[Render and Neon](docs/40-deployment/render-neon-self-host.md) or +[Heroku and Neon](docs/40-deployment/heroku-neon-self-host.md). ## Security diff --git a/app/business/extension/main.py b/app/business/extension/main.py index 935967b0..db307d7b 100644 --- a/app/business/extension/main.py +++ b/app/business/extension/main.py @@ -19,6 +19,7 @@ DisableExtensionCommand, EnableExtensionCommand, ExtensionManagementCommand, + InstallExtensionCommand, PatchExtensionConfigCommand, ) from app.schemas.peer import PeerProtocolRequest, PeerProtocolResponse, PeerRef @@ -218,6 +219,8 @@ async def manage_local( command: ExtensionManagementCommand, ) -> InstalledExtension: """Execute one already-validated command without entering delegation.""" + if isinstance(command, InstallExtensionCommand): + return await self.install(command.extension, command.version) if isinstance(command, EnableExtensionCommand): return await self.enable(command.extension) if isinstance(command, DisableExtensionCommand): diff --git a/app/http.py b/app/http.py new file mode 100644 index 00000000..992b0f17 --- /dev/null +++ b/app/http.py @@ -0,0 +1,13 @@ +"""Public HTTP address supplied by this Core's deployment configuration.""" + +from app.business.peer import PeerManager + + +async def get_public_http_base_url() -> str | None: + """Return the validated external base, preserving its path, or None if unset. + + Core-owned configuration remains stored on this Core's Peer row. Extensions + consume this address without depending on that persistence layout or guessing + a public URL from request headers or the listening socket. + """ + return (await PeerManager.get_current_config_async()).http_public_base_url diff --git a/app/schemas/extension/__init__.py b/app/schemas/extension/__init__.py index 46a3d203..207ad4ff 100644 --- a/app/schemas/extension/__init__.py +++ b/app/schemas/extension/__init__.py @@ -6,6 +6,7 @@ ExtensionManagementCommand, ExtensionModel, ExtensionName, + InstallExtensionCommand, PatchExtensionConfigCommand, ) @@ -17,5 +18,6 @@ "ExtensionManagementCommand", "ExtensionModel", "ExtensionName", + "InstallExtensionCommand", "PatchExtensionConfigCommand", ] diff --git a/app/schemas/extension/main.py b/app/schemas/extension/main.py index 3fb37edd..65fd9abf 100644 --- a/app/schemas/extension/main.py +++ b/app/schemas/extension/main.py @@ -19,6 +19,14 @@ ) +class InstallExtensionCommand(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + action: typing.Literal["install"] + extension: ExtensionName + version: str = pydantic.Field(pattern=EXTENSION_SEMVER_PATTERN) + + class EnableExtensionCommand(pydantic.BaseModel): model_config = pydantic.ConfigDict(extra="forbid", frozen=True) @@ -42,7 +50,10 @@ class PatchExtensionConfigCommand(pydantic.BaseModel): ExtensionManagementCommand: typing.TypeAlias = typing.Annotated[ - EnableExtensionCommand | DisableExtensionCommand | PatchExtensionConfigCommand, + InstallExtensionCommand + | EnableExtensionCommand + | DisableExtensionCommand + | PatchExtensionConfigCommand, pydantic.Field(discriminator="action"), ] diff --git a/app/version.py b/app/version.py index fdbd46cd..1b7d6e70 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -"""Single source of truth for the Core and Core Extension Host SDK version.""" +"""Core Extension Host SDK version, independent of the service package version.""" -CORE_VERSION = "0.2.0" +CORE_VERSION = "0.3.0" diff --git a/docs/30-unit-tdd/business-pipeline-and-authority.md b/docs/30-unit-tdd/business-pipeline-and-authority.md index 228370d2..7adfbe00 100644 --- a/docs/30-unit-tdd/business-pipeline-and-authority.md +++ b/docs/30-unit-tdd/business-pipeline-and-authority.md @@ -206,6 +206,9 @@ runtime 路径禁止同步 session、scoped session 和直接驱动连接;`scr - Business owners retain typed codecs and non-delegating local seams。Current exact inbounds are `core.semantic_retrieval.v1`、`core.feature_retrieval.lexical.v1`、`core.organization.rumination.v1` and exact-target `core.extension.management.v1`。 +- Extension management accepts exact-version `install` alongside enable, disable and config patch. It invokes the same + Core Host install boundary as REST, including compatibility and version-change guards; it neither enables the + Extension nor acquires a browser distribution. Older Peers reject the unknown action rather than routing elsewhere. - `core.peer.protocol.http.v1` owns normalized query/headers/body envelopes、Peer JWT and HTTP response projection。 Generic failover occurs only after pre-dispatch failure or exact `InkCre-Peer-Execution: not-executed`;a normal domain response or outcome-unknown stops。 diff --git a/docs/30-unit-tdd/memos-extension.md b/docs/30-unit-tdd/memos-extension.md index e5da0945..26236fba 100644 --- a/docs/30-unit-tdd/memos-extension.md +++ b/docs/30-unit-tdd/memos-extension.md @@ -50,12 +50,31 @@ Memos adapter 在 root 内组合: PAT 是 nullable ordinary extension config:`memos_pat_` 加 32 个 ASCII alphanumeric。它属于当前 deployment trust boundary,不建立 session、refresh token、PAT table 或 terminal-user record。比较在 -request time 执行,因此有效 config update 后 replace/revoke 立即生效,无需重建 routes。 +request time 执行。每个受保护请求通过现有 `EXTENSION_HOST.get` 读取 canonical config,再恢复 +`MemosConfig` 类型,不使用启动时的配置快照。因此有效保存完成后发起的请求采用新 PAT;撤销后 +旧 PAT 被拒绝,无需重建 routes。已通过鉴权的请求不会被追溯取消。读取失败不回退旧缓存, +invalid config 拒绝鉴权且不回显配置内容。这是一次短数据库读取,不建立配置同步或缓存机制。 ExtensionHost 的通用 config pipeline 先把 patch 与 current config shallow-merge,再用 `config_cls` 验证 complete next value,随后写 DB 并替换 live config。invalid config 不改变 durable/runtime state; disabled extension 可以预配置 PAT。 +Web 等已准入 Peer 也可通过普通数据库配置入口保存,不必将事实写入改成 Core 命令委派。 +上述 Memos 请求时读取覆盖这条正常顺序路径,不承诺所有 Extension 的内存资源都会自动重建。 + +## Connection Preparation + +运行中的 Memos 发布 `memos.connection.v1`,固定 GET `/memos/connection`,使用 Peer JWT, +返回仅含 `server_url` 的对象。地址由 Core 进程内 `app.http.get_public_http_base_url()` 和 Memos +自有挂载路径组成;Core-owned 配置仍是唯一公共基址权威,路径前缀保留,未配置时返回 409。 +Web 不解释 Peer config 或从 management advertisement 猜测 Memos 地址。该读取不返回 PAT, +不修改配置,也不是运行证明或外部客户端可达性检查;disable 随 Extension 生命周期撤销入口。 + +初始化沿用浏览器生成/复用 PAT、既有 patch_config 保存、尚未 enabled 才启用的顺序。 +在线 Peer + enabled 是用户流程的 best-effort 运行假设,不新增 running 状态或重复启用门槛。 +地址读取失败只报告该次失败,不撤销已保存配置或 enabled。外部 Memos API 仍使用 PAT, +Peer JWT 不因此成为 Memos 客户端凭据。 + Enable/start 发布一个 retained extension-owned route set;disable/close 直接从 FastAPI dispatch 与 OpenAPI surface 撤销这组 routes。Re-enable 重新发布同一 ownership surface,不重复注册。close cleanup 失败时 routes 仍保持撤销,runtime entry 保留,后续 close/enable 可以继续完成 reconciliation。 diff --git a/docs/40-deployment/README.md b/docs/40-deployment/README.md index 83155fd2..01b8c1c8 100644 --- a/docs/40-deployment/README.md +++ b/docs/40-deployment/README.md @@ -16,6 +16,7 @@ GitHub workflow and composite-action YAML owns only GitHub event selection, perm - [runtime-orchestration.md](runtime-orchestration.md) - [mcp-sink.md](mcp-sink.md) - [render-neon-self-host.md](render-neon-self-host.md) +- [heroku-neon-self-host.md](heroku-neon-self-host.md) ## Checked-In Runtime Anchors @@ -40,6 +41,7 @@ GitHub workflow and composite-action YAML owns only GitHub event selection, perm - `.github/workflows/preview-deploy.yml` - `.github/workflows/production-deploy.yml` - `.github/workflows/self-host-render-neon.yml` +- `.github/workflows/self-host-heroku-neon.yml` - `scripts/deploy_render_neon.py` - `scripts/generate-openapi.py` - `docs/openapi.json` diff --git a/docs/40-deployment/development-environment.md b/docs/40-deployment/development-environment.md index 2235a8e7..641b376a 100644 --- a/docs/40-deployment/development-environment.md +++ b/docs/40-deployment/development-environment.md @@ -27,6 +27,8 @@ tracked files never own a hostname, user, key, or machine path. The SSH provider sends an allowlisted build context and one bounded Compose payload to the remote host. Remote services publish dynamic remote-loopback ports and an instance-owned OpenSSH control tunnel maps independent local-loopback ports to them. +The descriptor keeps the absolute control-socket path, while SSH runs from its parent directory +with the short socket name so long worktree paths do not exceed Unix-domain socket limits. Runtime state is written to `.runtime/database//`. Its `runtime.json`, `profile.json`, and `readiness.json` record: diff --git a/docs/40-deployment/heroku-neon-self-host.md b/docs/40-deployment/heroku-neon-self-host.md new file mode 100644 index 00000000..103d2c87 --- /dev/null +++ b/docs/40-deployment/heroku-neon-self-host.md @@ -0,0 +1,70 @@ +# Self-Hosting On Heroku And Neon + +## Purpose And Boundary + +The checked-in `Deploy self-hosted InKCre to Heroku` workflow gives a repository owner a +browser-only path from a public `core-py` fork to two Heroku Container Stack apps backed by +the owner's Neon default branch: + +- `HEROKU_APP_PREFIX-core` runs the complete `core-py` Peer; +- `HEROKU_APP_PREFIX-postgrest` exposes the executable peer database contract. + +The workflow builds the selected commit as `linux/amd64` images, creates missing apps in the +Heroku US region, and runs one Eco web dyno for each app. Heroku charges and Eco sleep behavior +remain the deployment owner's responsibility. This deployment is independent from InKCre's +canonical production environment. + +The `JWT_SECRET` grants full admitted-Peer authority. Keep it private. The two database-role +passwords are also persistent deployment credentials: reruns must use the same values unless the +database roles and both app configurations are rotated together. + +## Browser-Only Onboarding + +1. Fork this repository and enable GitHub Actions for the fork. +2. Create a Neon project and an API key that can access it. Its default branch must retain the + standard `neondb` database and `neondb_owner` role. Copy the project ID. +3. Create a Heroku account with billing enabled and an API key. +4. In **Settings → Secrets and variables → Actions**, add these repository settings. + +| Kind | Exact name | Meaning | +| --- | --- | --- | +| Secret | `NEON_API_KEY` | Can resolve and mutate `NEON_PROJECT_ID` | +| Secret | `HEROKU_API_KEY` | Can create, configure, release, and scale the two apps | +| Secret | `JWT_SECRET` | At least 32 bytes; owner-only Peer signing authority | +| Secret | `CORE_DATABASE_PASSWORD` | At least 32 bytes; persistent `inkcre_core` role password | +| Secret | `POSTGREST_DATABASE_PASSWORD` | At least 32 bytes; persistent `authenticator` role password | +| Variable | `NEON_PROJECT_ID` | Target Neon project identity | +| Variable | `HEROKU_APP_PREFIX` | Unique 3–18 character lowercase app prefix | + +Use independently generated values for all three credential secrets. Do not reuse a provider API +key or another password as the JWT secret. + +5. Open **Actions → Deploy self-hosted InKCre to Heroku → Run workflow** and select the branch + to deploy. +6. Read the workflow summary for the public app URLs, Peer identity, exact commit, and Heroku + releases. Keep using the repository's private `JWT_SECRET` when an admitted client asks for + the signing key. + +## Convergence And Safety + +The workflow validates all settings before provider mutation, builds the exact selected commit, +resolves the Neon default branch owner coordinates, creates missing Heroku apps, and refuses an +existing app unless its deployment profile matches the same Neon project. It then initializes the +database contract, stores only role-specific URLs in Heroku config, releases both images, converges +the Core Peer's public address, and verifies Core readiness plus the authenticated PostgREST +read/write/deny contract. + +The Neon owner URL exists only in the GitHub Actions job. It is never placed in either Heroku app. +Workflow output contains no JWT, database password, or database URL. Rerunning the workflow is a +convergence operation and does not infer credential rotation or cleanup authority. + +Heroku's [Container Registry documentation](https://devcenter.heroku.com/articles/container-registry-and-runtime) +defines the image release and `linux/amd64` requirements. Its +[config-var documentation](https://devcenter.heroku.com/articles/config-vars) describes the +persistent runtime configuration used by this profile. + +## Cleanup + +Delete the two exact Heroku apps when the deployment is no longer wanted. Delete the Neon project +only if it is dedicated to this deployment and its data is disposable. Both actions are explicitly +owner-controlled and intentionally absent from the deployment workflow. diff --git a/docs/40-deployment/native-extension-distribution.md b/docs/40-deployment/native-extension-distribution.md index dd0b96b0..768b275d 100644 --- a/docs/40-deployment/native-extension-distribution.md +++ b/docs/40-deployment/native-extension-distribution.md @@ -95,9 +95,17 @@ Registry-origin database reads finish before Registry HTTP and wheel acquisition blocking artifact clients run in a worker without a database session. Startup failure or cancellation withdraws the current publication and releases its runtime claim. -First-party producer metadata targets >=0.2.0 <0.3.0. These changes require new immutable Extension +Core Host SDK 0.3 adds `app.http.get_public_http_base_url()` without changing the async lifecycle +or persistence API. Its authority is `app/version.py`, independently of the Core service package +version in `pyproject.toml`; preparing a service Release PR does not change the Host SDK version. + +GitHub, Learn English, Mail, RSS, Telegram, and Twitter retain their SDK 0.2 lower bound and support +SDK 0.3 through `>=0.2.0 <0.4.0`. Memos uses the new HTTP address API and requires +`>=0.3.0 <0.4.0`. Compatibility metadata changes require new immutable Extension releases; an old wheel's range must not be widened. Before upgrading a deployment, disable affected old Extensions, adopt the matching Core/Extension releases, restart where a loaded wheel was replaced, and then enable them. A persisted old enabled[] intent is not silently removed on failed cold restore. -The migration PR remains unreleasable until the complete runtime migration and compatible artifacts -are ready. +For Extensions with both Python and Module Federation distributions, prepare both at the same new +Extension version, even when the browser code is unchanged. Do not upgrade only the Python +association and lose the browser distribution required by the installed Extension. Compatible +artifacts must be available before the deployment upgrade. diff --git a/docs/_shared b/docs/_shared index 42f7bad1..5aa97e62 160000 --- a/docs/_shared +++ b/docs/_shared @@ -1 +1 @@ -Subproject commit 42f7bad1c61e57b5e0ebf55e27815ddc2ae913fa +Subproject commit 5aa97e6243dce02dd27c5f58eda345be2ba47297 diff --git a/extensions/github/.changes/+host-sdk-03.changed.md b/extensions/github/.changes/+host-sdk-03.changed.md new file mode 100644 index 00000000..04c61cb1 --- /dev/null +++ b/extensions/github/.changes/+host-sdk-03.changed.md @@ -0,0 +1 @@ +声明兼容 Core Host SDK 0.3,保留 SDK 0.2 支持;不改变插件功能。 diff --git a/extensions/github/pyproject.toml b/extensions/github/pyproject.toml index 646c7c86..b713c8eb 100644 --- a/extensions/github/pyproject.toml +++ b/extensions/github/pyproject.toml @@ -24,7 +24,7 @@ github = "extensions.github:Extension" name = "inkcre/github" nickname = "GitHub" host-sdk = "core-py" -host-sdk-version = ">=0.2.0 <0.3.0" +host-sdk-version = ">=0.2.0 <0.4.0" [tool.setuptools] packages = ["extensions.github"] diff --git a/extensions/learn_english/.changes/+host-sdk-03.changed.md b/extensions/learn_english/.changes/+host-sdk-03.changed.md new file mode 100644 index 00000000..04c61cb1 --- /dev/null +++ b/extensions/learn_english/.changes/+host-sdk-03.changed.md @@ -0,0 +1 @@ +声明兼容 Core Host SDK 0.3,保留 SDK 0.2 支持;不改变插件功能。 diff --git a/extensions/learn_english/pyproject.toml b/extensions/learn_english/pyproject.toml index e30dd6d1..8d82aac8 100644 --- a/extensions/learn_english/pyproject.toml +++ b/extensions/learn_english/pyproject.toml @@ -18,7 +18,7 @@ learn_english = "extensions.learn_english:Extension" name = "inkcre/learn-english" nickname = "Learn English" host-sdk = "core-py" -host-sdk-version = ">=0.2.0 <0.3.0" +host-sdk-version = ">=0.2.0 <0.4.0" [tool.setuptools] packages = ["extensions.learn_english"] diff --git a/extensions/mail/.changes/+host-sdk-03.changed.md b/extensions/mail/.changes/+host-sdk-03.changed.md new file mode 100644 index 00000000..04c61cb1 --- /dev/null +++ b/extensions/mail/.changes/+host-sdk-03.changed.md @@ -0,0 +1 @@ +声明兼容 Core Host SDK 0.3,保留 SDK 0.2 支持;不改变插件功能。 diff --git a/extensions/mail/pyproject.toml b/extensions/mail/pyproject.toml index dc5585b8..994efb8c 100644 --- a/extensions/mail/pyproject.toml +++ b/extensions/mail/pyproject.toml @@ -23,7 +23,7 @@ mail = "extensions.mail:Extension" name = "inkcre/mail" nickname = "Mail" host-sdk = "core-py" -host-sdk-version = ">=0.2.0 <0.3.0" +host-sdk-version = ">=0.2.0 <0.4.0" [tool.setuptools] packages = ["extensions.mail"] diff --git a/extensions/memos/.changes/+connection-address.added.md b/extensions/memos/.changes/+connection-address.added.md new file mode 100644 index 00000000..dd488c91 --- /dev/null +++ b/extensions/memos/.changes/+connection-address.added.md @@ -0,0 +1,2 @@ +通过 Memos 自有的 Peer 能力提供客户端连接地址,使用 Core 公共 HTTP 地址入口。 +鉴权读取当前已保存配置,使其他 Peer 正常修改或撤销 PAT 后的后续请求无需重启即可生效。 diff --git a/extensions/memos/__init__.py b/extensions/memos/__init__.py index d8f2216f..19a86312 100644 --- a/extensions/memos/__init__.py +++ b/extensions/memos/__init__.py @@ -20,10 +20,18 @@ def api_dependencies(cls): @classmethod def _register_apis(cls, router: fastapi.APIRouter): + from .connection import register_connection_route from .products.memos.v0_29_1 import register_backend + register_connection_route(router) register_backend(router) + @classmethod + def peer_inbounds(cls): + from .connection import MEMOS_CONNECTION_INBOUND + + return (MEMOS_CONNECTION_INBOUND,) + @classmethod def _init_resolvers(cls): from .family.attachment_resolver import AttachmentResolver # noqa: F401 diff --git a/extensions/memos/auth.py b/extensions/memos/auth.py index 3e3c62e5..d8310193 100644 --- a/extensions/memos/auth.py +++ b/extensions/memos/auth.py @@ -3,18 +3,27 @@ import secrets import fastapi +import pydantic +from app.business.extension import EXTENSION_HOST +from .config import MemosConfig -def require_memos_pat(request: fastapi.Request) -> None: - """Require the currently configured deployment-scoped Memos PAT.""" - from . import Extension +async def require_memos_pat(request: fastapi.Request) -> None: + """Require the currently configured deployment-scoped Memos PAT.""" auth_header = request.headers.get("Authorization") if not auth_header or not auth_header.startswith("Bearer "): raise _unauthorized() presented = auth_header[7:] - configured = Extension.config.personal_access_token + # Other admitted Peers can save configuration directly. Read its authority + # here so replacing or revoking a PAT does not require restarting this Host. + installed = await EXTENSION_HOST.get("inkcre/memos") + try: + configured = MemosConfig.model_validate(installed.config).personal_access_token + except pydantic.ValidationError: + # Validation details may echo credentials from an invalid direct DB write. + raise _unauthorized() from None if configured is None or not secrets.compare_digest(presented, configured): raise _unauthorized() diff --git a/extensions/memos/connection.py b/extensions/memos/connection.py new file mode 100644 index 00000000..1a0be7c2 --- /dev/null +++ b/extensions/memos/connection.py @@ -0,0 +1,29 @@ +"""Memos-owned connection address for admitted deployment Peers.""" + +import fastapi +import pydantic + +from app.business.peer import PeerHTTPInbound +from app.http import get_public_http_base_url +from app.middleware import require_peer_jwt + + +MEMOS_CONNECTION_INBOUND = PeerHTTPInbound( + "memos.connection.v1", "GET", "/memos/connection" +) + + +class MemosConnection(pydantic.BaseModel): + server_url: str + + +def register_connection_route(router: fastapi.APIRouter) -> None: + @router.get("/connection", dependencies=[fastapi.Depends(require_peer_jwt)]) + async def connection() -> MemosConnection: + base = await get_public_http_base_url() + if base is None: + raise fastapi.HTTPException( + status_code=409, + detail="Configure this Core's Public HTTP Base URL before connecting Memos.", + ) + return MemosConnection(server_url=f"{base}/memos") diff --git a/extensions/memos/docs/global/.vitepress/config.mts b/extensions/memos/docs/global/.vitepress/config.mts new file mode 100644 index 00000000..6e8d8745 --- /dev/null +++ b/extensions/memos/docs/global/.vitepress/config.mts @@ -0,0 +1,7 @@ +import { extensionDocs } from '../../../../../docs/_shared/website/extension-docs/preset.mts' + +export default extensionDocs({ + name: 'inkcre/memos', title: 'Memos for InKCre', scope: 'global', + description: 'Capture notes in InKCre with a Memos-compatible app.', + sidebar: [{ text: 'Overview', link: '/' }, { text: 'Connect Your App', link: '/connect' }], +}) diff --git a/extensions/memos/docs/global/.vitepress/theme/index.ts b/extensions/memos/docs/global/.vitepress/theme/index.ts new file mode 100644 index 00000000..e23b0685 --- /dev/null +++ b/extensions/memos/docs/global/.vitepress/theme/index.ts @@ -0,0 +1 @@ +export { default } from '../../../../../../docs/_shared/website/.vitepress/theme/index' diff --git a/extensions/memos/docs/global/connect.md b/extensions/memos/docs/global/connect.md new file mode 100644 index 00000000..90564cb4 --- /dev/null +++ b/extensions/memos/docs/global/connect.md @@ -0,0 +1,73 @@ +--- +description: Prepare a server URL and personal access token, then connect your Memos client. +outline: false +--- + +# Connect your app + +Prepare `inkcre/memos` on your Core. If you use the Web setup interface, install its matching Web +Distribution and enable it in your browser as well. Installation, Core enablement, and browser +enablement are separate actions; both Hosts share one installed Extension version. + +Your app needs two values: the Core server URL ending in `/memos`, and the Memos personal access +token (PAT). Neither is your PostgREST connection setting, and the PAT is not the instance JWT. + + + + + + +## Sign in from the client + +1. Open the client application's server sign-in screen. +2. Paste the exact Server URL, including `/memos`. +3. Paste the PAT into its personal-access-token field and sign in. +4. Confirm that the app opens normally. Preparing or copying credentials alone does not test the + phone's network access or the client's protocol compatibility. + +You can now deliberately create a note in the client. The setup interface itself does not create +test notes or collect information. Connecting here does not upload notes kept in an unrelated +account automatically. + +## Recover or disconnect + +If saving fails, correct the connection problem and retry with the retained draft. If saving +succeeds but Core enablement fails, retry enabling with the same saved PAT. After a timeout, refresh +the actual saved state before trying again. + +If client sign-in fails, first check that the device can reach the Core address and that Memos is +enabled there. A browser and a phone may have different network access. Also check the client's +version against the supported baseline; do not add API paths to the URL to guess around an error. + +To replace or revoke the PAT, explicitly change Extension Config. Replacing it affects +every client using the old PAT; update those clients manually. Disabling Memos on Core removes its +API routes. Closing the setup page does not disable the service or revoke its token. + +Configuration saved successfully applies to subsequent protected Memos requests, without a Core +restart. Requests that already passed authentication are not cancelled by a later revocation. diff --git a/extensions/memos/docs/global/index.md b/extensions/memos/docs/global/index.md new file mode 100644 index 00000000..356595b1 --- /dev/null +++ b/extensions/memos/docs/global/index.md @@ -0,0 +1,19 @@ +--- +description: Use a Memos-compatible app as a capture interface for InKCre. +--- + +# Memos-compatible capture + +The Memos Extension lets a compatible note-taking app connect directly to your InKCre instance. +InKCre provides the server; you do not need a separate Memos installation. Notes written through +that connection are saved in your InKCre information base. + +This is a write-in interface, not a Source that fetches another Memos server. There is no Source, +collection Job, or Cron to create. It does not import existing notes from another service. + +The current server implements a bounded subset of Memos 0.29.1. Its established compatibility +baseline is MoeMemos Android 2.0.4; this does not promise compatibility with every Memos client or +version. Unsupported server operations return errors rather than pretending to succeed. + +To start, [connect your app](connect). You need a running InKCre instance, the Memos Extension, +and a Core address reachable from the device running the app. diff --git a/extensions/memos/docs/python/.vitepress/config.mts b/extensions/memos/docs/python/.vitepress/config.mts new file mode 100644 index 00000000..c68f3957 --- /dev/null +++ b/extensions/memos/docs/python/.vitepress/config.mts @@ -0,0 +1,7 @@ +import { extensionDocs } from '../../../../../docs/_shared/website/extension-docs/preset.mts' + +export default extensionDocs({ + name: 'inkcre/memos', title: 'Memos Core Extension', scope: 'python', + description: 'Operate the Memos-compatible backend on Core.', + sidebar: [{ text: 'Core Configuration', link: '/' }], +}) diff --git a/extensions/memos/docs/python/.vitepress/theme/index.ts b/extensions/memos/docs/python/.vitepress/theme/index.ts new file mode 100644 index 00000000..e23b0685 --- /dev/null +++ b/extensions/memos/docs/python/.vitepress/theme/index.ts @@ -0,0 +1 @@ +export { default } from '../../../../../../docs/_shared/website/.vitepress/theme/index' diff --git a/extensions/memos/docs/python/index.md b/extensions/memos/docs/python/index.md new file mode 100644 index 00000000..2b363523 --- /dev/null +++ b/extensions/memos/docs/python/index.md @@ -0,0 +1,37 @@ +--- +description: Install, configure, and operate the Python Memos Extension. +--- + +# Memos on Core + +The Python Distribution exposes the Memos-compatible backend under the selected Core's `/memos` +route. Its configuration has one nullable field, `personal_access_token`. A configured token is +`memos_pat_` followed by exactly 32 ASCII letters or digits. A null value does not authorize any +client's protected requests. + +Use the existing `core.extension.management.v1` capability to apply a `patch_config` command to +`inkcre/memos`, then `enable` on the intended Core. Configuration can be saved before enablement. +The Core validates the complete resulting config, persists it, and updates the live Extension. +Memos authentication reads the current saved configuration on each protected request, including +valid configuration saved by another admitted Peer through the ordinary Config interface. + +The PAT is deployment configuration, not an OAuth session or a user-account token store. Replacing +or revoking it takes effect on subsequent protected requests and affects every connected client. +Keep it out of logs, URLs, screenshots, and public documentation artifacts. + +Set Core's `http_public_base_url` to the externally reachable base URL. The client-facing server +address appends `/memos`; a deployment mounted below a base path must retain that path. HTTPS +termination and any necessary forwarding remain deployment responsibilities. The Extension does +not create tunnels or make a private address reachable from a phone. + +The Web setup reads the complete address through the Memos-owned `memos.connection.v1` Peer +capability. It does not interpret Core's Peer configuration. That read requires a Peer JWT and +returns only the Server URL; it does not test the phone's connectivity or expose the PAT. + +For a non-mutating authentication check, request `GET /memos/api/v1/auth/me` with the PAT as a Bearer +token. This proves the route and token work from that caller, not that every Memos client is +compatible. The public instance profile does not require a PAT and is not an authentication check. + +Enablement mounts the Extension's routes; disablement withdraws them. Installation, the per-Peer +enabled intent, and the running process remain separate states. Runtime and graph implementation +details are maintained in the producer's Memos Unit TDD, not duplicated in this operator guide. diff --git a/extensions/memos/pyproject.toml b/extensions/memos/pyproject.toml index 88e55641..201f7875 100644 --- a/extensions/memos/pyproject.toml +++ b/extensions/memos/pyproject.toml @@ -22,7 +22,7 @@ memos = "extensions.memos:Extension" name = "inkcre/memos" nickname = "Memos" host-sdk = "core-py" -host-sdk-version = ">=0.2.0 <0.3.0" +host-sdk-version = ">=0.3.0 <0.4.0" [tool.setuptools] packages = [ diff --git a/extensions/rss/.changes/+host-sdk-03.changed.md b/extensions/rss/.changes/+host-sdk-03.changed.md new file mode 100644 index 00000000..04c61cb1 --- /dev/null +++ b/extensions/rss/.changes/+host-sdk-03.changed.md @@ -0,0 +1 @@ +声明兼容 Core Host SDK 0.3,保留 SDK 0.2 支持;不改变插件功能。 diff --git a/extensions/rss/pyproject.toml b/extensions/rss/pyproject.toml index ec74f35e..fe23f244 100644 --- a/extensions/rss/pyproject.toml +++ b/extensions/rss/pyproject.toml @@ -28,7 +28,7 @@ rss = "extensions.rss:Extension" name = "inkcre/rss" nickname = "RSS/Atom Feeds" host-sdk = "core-py" -host-sdk-version = ">=0.2.0 <0.3.0" +host-sdk-version = ">=0.2.0 <0.4.0" [tool.setuptools] packages = ["extensions.rss"] diff --git a/extensions/telegram/.changes/+host-sdk-03.changed.md b/extensions/telegram/.changes/+host-sdk-03.changed.md new file mode 100644 index 00000000..04c61cb1 --- /dev/null +++ b/extensions/telegram/.changes/+host-sdk-03.changed.md @@ -0,0 +1 @@ +声明兼容 Core Host SDK 0.3,保留 SDK 0.2 支持;不改变插件功能。 diff --git a/extensions/telegram/pyproject.toml b/extensions/telegram/pyproject.toml index 9f35e6ea..294142a6 100644 --- a/extensions/telegram/pyproject.toml +++ b/extensions/telegram/pyproject.toml @@ -24,7 +24,7 @@ telegram = "extensions.telegram:Extension" name = "inkcre/telegram" nickname = "Telegram" host-sdk = "core-py" -host-sdk-version = ">=0.2.0 <0.3.0" +host-sdk-version = ">=0.2.0 <0.4.0" [tool.setuptools] packages = ["extensions.telegram"] diff --git a/extensions/twitter/.changes/+host-sdk-03.changed.md b/extensions/twitter/.changes/+host-sdk-03.changed.md new file mode 100644 index 00000000..04c61cb1 --- /dev/null +++ b/extensions/twitter/.changes/+host-sdk-03.changed.md @@ -0,0 +1 @@ +声明兼容 Core Host SDK 0.3,保留 SDK 0.2 支持;不改变插件功能。 diff --git a/extensions/twitter/pyproject.toml b/extensions/twitter/pyproject.toml index 58e8ab87..3616a455 100644 --- a/extensions/twitter/pyproject.toml +++ b/extensions/twitter/pyproject.toml @@ -26,7 +26,7 @@ twitter = "extensions.twitter:Extension" name = "inkcre/twitter" nickname = "Twitter" host-sdk = "core-py" -host-sdk-version = ">=0.2.0 <0.3.0" +host-sdk-version = ">=0.2.0 <0.4.0" [tool.setuptools] packages = ["extensions.twitter"] diff --git a/scripts/automation/production_delivery.sh b/scripts/automation/production_delivery.sh index 09c960b2..14d5b31a 100644 --- a/scripts/automation/production_delivery.sh +++ b/scripts/automation/production_delivery.sh @@ -44,6 +44,15 @@ latest_release() { heroku releases --app "$1" --json | jq -r '.[0].version // empty' } +require_deployment_profile() { + local app_name="$1" actual + actual="$(heroku config:get INKCRE_DEPLOYMENT_PROFILE --app "$app_name" || true)" + if [ "$actual" != "$EXPECTED_DEPLOYMENT_PROFILE" ]; then + echo "Refusing existing Heroku app $app_name: deployment profile does not match" >&2 + return 1 + fi +} + wait_if_changed() { local app_name="$1" local before="$2" @@ -79,15 +88,39 @@ case "${1:-}" in require_env HEROKU_API_KEY require_env HEROKU_APP_NAME require_env POSTGREST_APP_NAME - if ! app_json="$(heroku apps:info --app "$HEROKU_APP_NAME" --json 2>/dev/null)"; then + core_app_created=false + app_json="$(heroku apps:info --app "$HEROKU_APP_NAME" --json 2>/dev/null || true)" + postgrest_json="$(heroku apps:info --app "$POSTGREST_APP_NAME" --json 2>/dev/null || true)" + if [ -n "${EXPECTED_DEPLOYMENT_PROFILE:-}" ]; then + if [ -n "$app_json" ]; then + require_deployment_profile "$HEROKU_APP_NAME" + fi + if [ -n "$postgrest_json" ]; then + require_deployment_profile "$POSTGREST_APP_NAME" + fi + fi + if [ -z "$app_json" ]; then heroku apps:create "$HEROKU_APP_NAME" --region us --stack container app_json="$(heroku apps:info --app "$HEROKU_APP_NAME" --json)" + core_app_created=true + if [ -n "${EXPECTED_DEPLOYMENT_PROFILE:-}" ]; then + heroku config:set --app "$HEROKU_APP_NAME" \ + "INKCRE_DEPLOYMENT_PROFILE=$EXPECTED_DEPLOYMENT_PROFILE" >/dev/null + fi fi - if ! postgrest_json="$(heroku apps:info --app "$POSTGREST_APP_NAME" --json 2>/dev/null)"; then + postgrest_app_created=false + if [ -z "$postgrest_json" ]; then heroku apps:create "$POSTGREST_APP_NAME" --region us --stack container postgrest_json="$(heroku apps:info --app "$POSTGREST_APP_NAME" --json)" + postgrest_app_created=true + if [ -n "${EXPECTED_DEPLOYMENT_PROFILE:-}" ]; then + heroku config:set --app "$POSTGREST_APP_NAME" \ + "INKCRE_DEPLOYMENT_PROFILE=$EXPECTED_DEPLOYMENT_PROFILE" >/dev/null + fi fi emit_output app_name "$HEROKU_APP_NAME" + emit_output core_app_created "$core_app_created" + emit_output postgrest_app_created "$postgrest_app_created" emit_output postgrest_app_name "$POSTGREST_APP_NAME" emit_output postgrest_url "$(jq -r '.app.web_url' <<<"$postgrest_json")" emit_output web_url "$(jq -r '.app.web_url' <<<"$app_json")" @@ -100,7 +133,8 @@ case "${1:-}" in require_env POSTGREST_DATABASE_PASSWORD docker run --rm --env CORE_DATABASE_PASSWORD --env MIGRATION_DATABASE_URL \ --env POSTGREST_DATABASE_PASSWORD "inkcre-production-web:$HEAD_SHA" \ - python scripts/container.py db init --profile runtime --environment production + python scripts/container.py db init --profile runtime \ + --environment "${DATABASE_ENVIRONMENT:-production}" docker run --rm --env MIGRATION_DATABASE_URL "inkcre-production-web:$HEAD_SHA" \ python scripts/container.py db ready --profile runtime --json core_database_url="$( @@ -127,12 +161,19 @@ case "${1:-}" in POSTGREST_APP_NAME POSTGREST_DATABASE_URL; do require_env "$name" done + core_profile=() + postgrest_profile=() + if [ -n "${INKCRE_DEPLOYMENT_PROFILE:-}" ]; then + core_profile+=("INKCRE_DEPLOYMENT_PROFILE=$INKCRE_DEPLOYMENT_PROFILE") + postgrest_profile+=("INKCRE_DEPLOYMENT_PROFILE=$INKCRE_DEPLOYMENT_PROFILE") + fi before="$(latest_release "$APP_NAME")" peer_id="$(python3 -c "import uuid; print(uuid.uuid5(uuid.NAMESPACE_URL, '$APP_NAME'))")" heroku config:set --app "$APP_NAME" \ DATABASE_SCALE_0=true "DATABASE_URL=$DATABASE_URL" INKCRE_ENV_FILE= \ "JWT_SECRET=$JWT_SECRET" OBSRV__LOGGING_BACKEND=none "PEER_ID=$peer_id" \ - PEER_NAME=core-py-production SKIP_EXTENSIONS_SYNC=0 >/dev/null + "PEER_NAME=${PEER_NAME:-core-py-production}" SKIP_EXTENSIONS_SYNC=0 \ + "${core_profile[@]}" >/dev/null heroku config:unset CLIENT_BASE_URL CLIENT_ID CLIENT_NAME LLM_SP_AK \ LLM_SP_BASE_URL --app "$APP_NAME" >/dev/null || true if [ -n "$(heroku config:get MIGRATION_DATABASE_URL --app "$APP_NAME" || true)" ]; then @@ -145,7 +186,7 @@ case "${1:-}" in PGRST_DB_ANON_ROLE=anonymous PGRST_DB_POOL=2 \ PGRST_DB_PRE_REQUEST=inkcre_internal.check_jwt PGRST_DB_SCHEMAS=inkcre \ "PGRST_DB_URI=$POSTGREST_DATABASE_URL" PGRST_JWT_AUD=inkcre-api \ - "PGRST_JWT_SECRET=$JWT_SECRET" >/dev/null + "PGRST_JWT_SECRET=$JWT_SECRET" "${postgrest_profile[@]}" >/dev/null wait_if_changed "$POSTGREST_APP_NAME" "$before" >/dev/null for attempt in $(seq 1 5); do @@ -199,14 +240,14 @@ case "${1:-}" in scripts/verify_postgrest_contract.py --base-url "$POSTGREST_URL" \ --jwt-secret "$JWT_SECRET" --wrong-jwt-secret "wrong-$JWT_SECRET"; then append_summary <&2 + exit 1 + fi + for name in CORE_DATABASE_PASSWORD HEROKU_API_KEY HEROKU_APP_PREFIX JWT_SECRET \ + NEON_API_KEY NEON_PROJECT_ID POSTGREST_DATABASE_PASSWORD; do require_env "$name"; done + [[ "$HEROKU_APP_PREFIX" =~ ^[a-z][a-z0-9-]{1,16}[a-z0-9]$ ]] + test "${#JWT_SECRET}" -ge 32 + test "${#CORE_DATABASE_PASSWORD}" -ge 32 + test "${#POSTGREST_DATABASE_PASSWORD}" -ge 32 + emit_output app_name "$HEROKU_APP_PREFIX-core" + emit_output deployment_profile "core-py.heroku-neon.v1:$NEON_PROJECT_ID" + emit_output postgrest_app_name "$HEROKU_APP_PREFIX-postgrest" + ;; resolve-neon) for name in NEON_API_KEY NEON_PROJECT_ID; do require_env "$name"; done cli=(npx --yes neonctl@2.36.0) @@ -46,5 +61,8 @@ case "${1:-}" in - Admission: private JWT secret from this repository EOF ;; - *) echo "usage: $0 validate|resolve-neon|summarize" >&2; exit 2 ;; + *) + echo "usage: $0 validate|validate-heroku|resolve-neon|summarize" >&2 + exit 2 + ;; esac diff --git a/scripts/dev_database_provider.py b/scripts/dev_database_provider.py index abfaf49d..bd6e7b42 100644 --- a/scripts/dev_database_provider.py +++ b/scripts/dev_database_provider.py @@ -418,11 +418,12 @@ def database_access_ready( ( "ssh", "-S", - control_socket_path, + Path(control_socket_path).name, "-O", "check", provider.target, ), + cwd=Path(control_socket_path).parent, timeout=5, ) except (OSError, subprocess.SubprocessError): @@ -461,7 +462,7 @@ def open_database_access( "ssh", "-M", "-S", - str(socket_path), + socket_path.name, "-fnNT", "-o", "BatchMode=yes", @@ -470,6 +471,8 @@ def open_database_access( *forwards, provider.target, ), + # OpenSSH appends a temporary suffix; absolute worktree paths can exceed AF_UNIX limits. + cwd=socket_path.parent, timeout=15, ) return str(socket_path) @@ -487,11 +490,12 @@ def close_database_access( ( "ssh", "-S", - control_socket_path, + Path(control_socket_path).name, "-O", "exit", provider.target, ), + cwd=Path(control_socket_path).parent, timeout=5, ) except (OSError, subprocess.SubprocessError): diff --git a/scripts/release.py b/scripts/release.py index 6e2be11d..83b72af7 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -237,6 +237,8 @@ def affected_projects(base: str) -> set[str]: for path in paths: parts = path.split("/") if len(parts) >= 2 and parts[0] == "extensions" and parts[1] in extension_keys: + if len(parts) >= 3 and parts[2] == "docs": + continue if FRAGMENTS_NAME not in parts and parts[-1] != CHANGELOG_NAME: affected.add(parts[1]) elif path.startswith(core_prefixes) or path in core_files: diff --git a/tasks/heroku-self-hosting/packet.md b/tasks/heroku-self-hosting/packet.md new file mode 100644 index 00000000..c24dfe67 --- /dev/null +++ b/tasks/heroku-self-hosting/packet.md @@ -0,0 +1,8 @@ + +# heroku-self-hosting + +- **Objective**: A fork owner can manually converge the selected commit to two Heroku Eco apps backed by the Neon default branch, with the same observable Core and PostgREST contract as the existing Render self-host profile. +- **Guardrails**: Keep Render unchanged and canonical production defaults unchanged. Never persist the Neon owner URL in Heroku, print JWT/database passwords, rotate role passwords on rerun, adopt an app owned by another self-host profile, or infer cleanup authority. +- **Verification**: Shell syntax, workflow YAML parsing, `git diff --check`, `pdm run check`, and release checks pass. Two credentialed runs from `xiaoland/core-py` converged exact commit `c6e1d45`: [first run](https://github.com/xiaoland/core-py/actions/runs/35494310828) and [rerun](https://github.com/xiaoland/core-py/actions/runs/35494557546). Direct probes returned Core `/livez` 200, `/readyz` 200, and anonymous PostgREST 401; both Eco web dynos were up. +- **Current Truth**: The dedicated Heroku workflow builds the selected commit, reuses the existing Heroku delivery mechanics with production-preserving defaults, binds app ownership to the selected Neon project, and documents five secrets plus two variables. First deployment and same-input convergence are proven against the deployment owner's real Heroku and Neon accounts. Render remains unchanged. +- **Next Step**: Human review of draft PR #110. diff --git a/tests/extensions/memos/integration/test_postgresql_attachment_graph.py b/tests/extensions/memos/integration/test_postgresql_attachment_graph.py index b9ad2278..9d5a4fc6 100644 --- a/tests/extensions/memos/integration/test_postgresql_attachment_graph.py +++ b/tests/extensions/memos/integration/test_postgresql_attachment_graph.py @@ -94,6 +94,7 @@ def memo_client(): Extension, {"personal_access_token": "memos_pat_" + "A" * 32}, raise_server_exceptions=False, + persist_config=True, ) with published.client as client: try: @@ -104,6 +105,73 @@ def memo_client(): published.unpublish() +def test_saved_pat_replacement_and_revocation_apply_without_restart(memo_client): + """An admitted Peer can edit config without invoking the running Host.""" + from app.schemas.extension import ExtensionModel + + old_token = "memos_pat_" + "A" * 32 + new_token = "memos_pat_" + "B" * 32 + + def authenticate(token: str): + return memo_client.get( + "/memos/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"} + ) + + def save(token: str | None): + with TestSession() as session: + installed = session.get(ExtensionModel, "inkcre/memos") + assert installed is not None + installed.config = {"personal_access_token": token} + session.add(installed) + session.commit() + + assert authenticate(old_token).status_code == 200 + save(new_token) + assert authenticate(old_token).status_code == 401 + assert authenticate(new_token).status_code == 200 + save(None) + assert authenticate(new_token).status_code == 401 + assert memo_client.get("/memos/api/v1/instance/profile").status_code == 200 + + +def test_connection_address_preserves_public_prefix_and_requires_peer_auth(memo_client): + from app.business.peer import PeerManager + from app.middleware import create_peer_jwt + from app.schemas.peer import PeerModel + from app.settings import settings + + client = memo_client + assert client.portal is not None + client.portal.call(PeerManager.register_self) + with TestSession() as session: + peer = session.get(PeerModel, settings.peer_id) + assert peer is not None + peer.config = {"http_public_base_url": "https://example.test/inkcre/"} + session.add(peer) + session.commit() + + assert client.get("/memos/connection").status_code == 401 + assert ( + client.get( + "/memos/connection", + headers={"Authorization": "Bearer memos_pat_" + "A" * 32}, + ).status_code + == 401 + ) + headers = {"Authorization": f"Bearer {create_peer_jwt(settings.jwt_secret)}"} + response = client.get("/memos/connection", headers=headers) + assert response.status_code == 200 + assert response.json() == {"server_url": "https://example.test/inkcre/memos"} + + with TestSession() as session: + peer = session.get(PeerModel, settings.peer_id) + assert peer is not None + peer.config = {} + session.add(peer) + session.commit() + assert client.get("/memos/connection", headers=headers).status_code == 409 + + @pytest.mark.parametrize( ("filename", "media_type", "resolver_id"), ( diff --git a/tests/extensions/runtime_support.py b/tests/extensions/runtime_support.py index 943f9aaa..5789ec2f 100644 --- a/tests/extensions/runtime_support.py +++ b/tests/extensions/runtime_support.py @@ -2,6 +2,7 @@ import asyncio from dataclasses import dataclass +import os import typing import fastapi @@ -53,11 +54,27 @@ def publish_extension( *, app: fastapi.FastAPI | None = None, raise_server_exceptions: bool = True, + persist_config: bool = False, ) -> PublishedExtension: runtime_app = app or fastapi.FastAPI() runtime_config = dict(config or {}) runtime_state: dict[str, typing.Any] = {} + if persist_config: + if not os.getenv("INKCRE_TEST_DATABASE_URL"): + raise RuntimeError("Persisted Extension setup requires an explicit test database") + from app.schemas.extension import ExtensionModel + from tests.database import TestSession + + with TestSession() as session: + name = f"inkcre/{extension.__extid__}" + installed = session.get(ExtensionModel, name) + if installed is None: + installed = ExtensionModel(name=name, version="0.0.0") + installed.config = runtime_config + session.add(installed) + session.commit() + extension.unpublish() extension.unbind() extension.release_runtime() diff --git a/tests/semantic_retrieval/acceptance/test_vertical_quality.py b/tests/semantic_retrieval/acceptance/test_vertical_quality.py index 4336668c..1016e3bd 100644 --- a/tests/semantic_retrieval/acceptance/test_vertical_quality.py +++ b/tests/semantic_retrieval/acceptance/test_vertical_quality.py @@ -207,6 +207,7 @@ def _memos_client() -> TestClient: return publish_extension( MemosExtension, {"personal_access_token": PAT}, + persist_config=True, ).client