Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
ed27081
feat(ai): add bounded gateway keyring configuration
seonghobae Aug 4, 2026
7b9ae10
test(ai): cover gateway keyring rotation contracts
seonghobae Aug 4, 2026
473c54e
docs(ai): record gateway key rotation standards
seonghobae Aug 4, 2026
0eecc5a
docs(ai): add staged gateway key rotation runbook
seonghobae Aug 4, 2026
38cdf79
docs(ai): design bounded gateway key rotation
seonghobae Aug 4, 2026
cbf00e9
docs(ai): plan zero-downtime gateway key rotation slice
seonghobae Aug 4, 2026
0487757
style(ai): format keyring test table
seonghobae Aug 4, 2026
c296dff
ci: format AI gateway keyring evidence
seonghobae Aug 4, 2026
eee4a3b
style(ai): format gateway keyring evidence
github-actions[bot] Aug 4, 2026
573c354
ci: remove temporary keyring formatter
seonghobae Aug 4, 2026
bdf4959
docs(ai): document gateway key error constructors
seonghobae Aug 4, 2026
3f0154d
test(ai): handle empty rejected key material safely
seonghobae Aug 4, 2026
901ed7a
fix(ai): reject reused gateway rotation material
seonghobae Aug 4, 2026
120db48
test(ai): cover duplicate gateway key material
seonghobae Aug 4, 2026
8176aa9
feat(ai): verify versioned keyed gateway context
seonghobae Aug 4, 2026
dcefa96
refactor(ai): fail closed on gateway key errors
seonghobae Aug 4, 2026
99524dd
test(ai): cover active overlap and retired gateway keys
seonghobae Aug 4, 2026
c91b683
feat(ai): require keyed context on every controller
seonghobae Aug 4, 2026
0840806
ci(ai): wire and verify gateway key rotation
seonghobae Aug 4, 2026
4c456c6
ci(ai): repair key rotation helper action pin
seonghobae Aug 4, 2026
99c8229
ci(ai): execute key rotation codemod with valid action pins
seonghobae Aug 4, 2026
b5e273b
fix(ci): defer malformed integration codemod segment
seonghobae Aug 4, 2026
e8f3648
feat(ai): wire versioned keyed gateway context
github-actions[bot] Aug 4, 2026
96e100f
fix(ci): format and verify keyed gateway context
seonghobae Aug 4, 2026
7f42499
style(ai): format keyed gateway context slice
github-actions[bot] Aug 4, 2026
175efc5
fix(ci): repair explicit signing key evidence
seonghobae Aug 4, 2026
03928b8
fix(ci): use structural signing-key test repair
seonghobae Aug 4, 2026
9b5904e
fix(ai): pass explicit signing key in web evidence
github-actions[bot] Aug 4, 2026
7926cdf
test(ai): wire PostgreSQL key rotation lifecycle evidence
seonghobae Aug 4, 2026
400be9e
fix(ci): match wrapped gateway secret restoration
seonghobae Aug 4, 2026
ca4c18d
test(ai): prove zero-downtime gateway key rotation
github-actions[bot] Aug 4, 2026
321a93f
docs(ai): finalize rotation standards and release evidence
seonghobae Aug 4, 2026
56ca9c2
chore(ai): add temporary rotation evidence finalizer
seonghobae Aug 4, 2026
6ed34cb
fix(ci): run rotation finalizer from a valid workflow
seonghobae Aug 4, 2026
83c61fe
docs(ai): finalize gateway key rotation evidence
github-actions[bot] Aug 4, 2026
75b823d
chore(ai): remove completed key rotation helper workflow
seonghobae Aug 4, 2026
a2ce614
chore(ai): remove completed key rotation fixer workflow
seonghobae Aug 4, 2026
8c6d85d
chore(ai): remove completed key rotation verification workflow
seonghobae Aug 4, 2026
a35a609
chore(ai): remove completed rotation evidence finalizer
seonghobae Aug 4, 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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ IDENTITY_SERVICE_ORIGIN=http://127.0.0.1:4101
PLANNING_SERVICE_ORIGIN=http://127.0.0.1:4102
PLANNING_GATEWAY_CONTEXT_SECRET=replace-with-at-least-32-random-bytes
AI_SERVICE_ORIGIN=http://127.0.0.1:4105
AI_GATEWAY_CONTEXT_SECRET=replace-with-at-least-32-random-bytes
AI_GATEWAY_ACTIVE_KEY_ID=gateway-2026-08-a
AI_GATEWAY_ACTIVE_KEY_SECRET=replace-with-at-least-32-random-bytes
AI_GATEWAY_PREVIOUS_KEY_ID=
AI_GATEWAY_PREVIOUS_KEY_SECRET=
SESSION_SECRET=replace-with-at-least-32-random-bytes
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
Expand Down
545 changes: 545 additions & 0 deletions .github/workflows/complete-ai-key-rotation.yml

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions .github/workflows/fix-ai-key-rotation-helper.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Fix AI Key Rotation Helper

on:
push:
branches:
- feat/ai-gateway-key-rotation
paths:
- '.github/workflows/fix-ai-key-rotation-helper.yml'

permissions:
contents: write
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
fix:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Checkout feature branch
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: feat/ai-gateway-key-rotation
fetch-depth: 0

- name: Repair helper action pin and remove this fixer
run: |
set -Eeuo pipefail
sed -i \
's#actions/setup-node@fdbd5b5d6a1c9bb1f77880bc2f733d820f4737e0 #actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #' \
.github/workflows/complete-ai-key-rotation.yml
git rm .github/workflows/fix-ai-key-rotation-helper.yml
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add .github/workflows/complete-ai-key-rotation.yml
git diff --cached --check
git commit -m 'fix(ci): use available setup-node action pin'
git push origin HEAD:feat/ai-gateway-key-rotation
255 changes: 255 additions & 0 deletions .github/workflows/run-ai-key-rotation-codemod.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
name: Verify AI Gateway Rotation Integration

on:
push:
branches:
- feat/ai-gateway-key-rotation
paths:
- '.github/workflows/run-ai-key-rotation-codemod.yml'

permissions:
contents: write
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
AI_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_ai_key_rotation_test
AI_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_ai_key_rotation_test

jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 30
services:
postgres:
image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
env:
POSTGRES_DB: life_os_ai_key_rotation_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d life_os_ai_key_rotation_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- name: Checkout feature branch
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: feat/ai-gateway-key-rotation
fetch-depth: 0

- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22

- name: Enable Corepack and install dependencies
run: |
corepack enable
pnpm install --frozen-lockfile

- name: Wire active and previous keys into PostgreSQL HTTP evidence
run: |
set -Eeuo pipefail
python3 - <<'PY'
from pathlib import Path

path = Path('apps/ai-service/src/proposal-audit-http.integration.test.ts')
source = path.read_text(encoding='utf-8')

def replace_once(old: str, new: str, label: str) -> None:
global source
count = source.count(old)
if count != 1:
raise SystemExit(f'{label}: expected one match, found {count}')
source = source.replace(old, new, 1)

replace_once(
"""const ORIGINAL_APPLICATION_DATABASE_URL = process.env.AI_DATABASE_URL;
const ORIGINAL_GATEWAY_CONTEXT_SECRET = process.env.AI_GATEWAY_CONTEXT_SECRET;
const GATEWAY_CONTEXT_SECRET = 'ai-http-integration-gateway-secret-32-bytes';
""".replace(' ', ''),
"""const ORIGINAL_APPLICATION_DATABASE_URL = process.env.AI_DATABASE_URL;
const ORIGINAL_ACTIVE_KEY_ID = process.env.AI_GATEWAY_ACTIVE_KEY_ID;
const ORIGINAL_ACTIVE_KEY_SECRET = process.env.AI_GATEWAY_ACTIVE_KEY_SECRET;
const ORIGINAL_PREVIOUS_KEY_ID = process.env.AI_GATEWAY_PREVIOUS_KEY_ID;
const ORIGINAL_PREVIOUS_KEY_SECRET =
process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET;
const ACTIVE_KEY_ID = 'gateway-2026-08-b';
const PREVIOUS_KEY_ID = 'gateway-2026-07-a';
const ACTIVE_KEY_SECRET = Buffer.alloc(32, 0x41).toString('base64url');
const PREVIOUS_KEY_SECRET = Buffer.alloc(32, 0x42).toString('base64url');
""".replace(' ', ''),
'key environment constants',
)

replace_once(
""" issuedAt?: number;
secret?: string;
}): Readonly<Record<string, string>> {
""".replace(' ', ''),
""" issuedAt?: number;
keyId?: string;
secret?: string;
}): Readonly<Record<string, string>> {
""".replace(' ', ''),
'signed context input',
)

replace_once(
""" const issuedAt = String(input.issuedAt ?? Math.floor(Date.now() / 1000));
const signature = createHmac('sha256', input.secret ?? GATEWAY_CONTEXT_SECRET)
.update(
`life-os.ai-context.v1\\n${workspaceId}\\n${actorId}\\n${issuedAt}\\n${input.method}\\n${input.path}`,
'utf8',
)
.digest('base64url');
return {
'x-life-os-workspace-id': workspaceId,
""".replace(' ', ''),
""" const issuedAt = String(input.issuedAt ?? Math.floor(Date.now() / 1000));
const keyId = input.keyId ?? ACTIVE_KEY_ID;
const signature = createHmac('sha256', input.secret ?? ACTIVE_KEY_SECRET)
.update(
`life-os.ai-context.v2\\n${keyId}\\n${workspaceId}\\n${actorId}\\n${issuedAt}\\n${input.method}\\n${input.path}`,
'utf8',
)
.digest('base64url');
return {
'x-life-os-context-key-id': keyId,
'x-life-os-workspace-id': workspaceId,
""".replace(' ', ''),
'v2 signature and key header',
)

replace_once(
""" process.env.AI_DATABASE_URL = testDatabaseUrl;
process.env.AI_GATEWAY_CONTEXT_SECRET = GATEWAY_CONTEXT_SECRET;
""".replace(' ', ''),
""" process.env.AI_DATABASE_URL = testDatabaseUrl;
process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID;
process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET;
delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID;
delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET;
""".replace(' ', ''),
'beforeAll key setup',
)

replace_once(
""" if (ORIGINAL_GATEWAY_CONTEXT_SECRET === undefined) {
delete process.env.AI_GATEWAY_CONTEXT_SECRET;
} else {
process.env.AI_GATEWAY_CONTEXT_SECRET = ORIGINAL_GATEWAY_CONTEXT_SECRET;
}
""".replace(' ', ''),
""" for (const [name, value] of [
['AI_GATEWAY_ACTIVE_KEY_ID', ORIGINAL_ACTIVE_KEY_ID],
['AI_GATEWAY_ACTIVE_KEY_SECRET', ORIGINAL_ACTIVE_KEY_SECRET],
['AI_GATEWAY_PREVIOUS_KEY_ID', ORIGINAL_PREVIOUS_KEY_ID],
['AI_GATEWAY_PREVIOUS_KEY_SECRET', ORIGINAL_PREVIOUS_KEY_SECRET],
] as const) {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
""".replace(' ', ''),
'afterAll key restoration',
)

marker = """ it('persists proposals across restarts and appends replay-safe decisions', async () => {
""".replace(' ', '')
rotation_test = """ it('accepts the previous key during overlap and rejects it immediately after retirement', async () => {
const workspaceId = randomUUID();
const actorId = randomUUID();
process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID;
process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET;
process.env.AI_GATEWAY_PREVIOUS_KEY_ID = PREVIOUS_KEY_ID;
process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET = PREVIOUS_KEY_SECRET;
const app = await NestFactory.create(AiProductionModule, { logger: false });
await app.listen(0, '127.0.0.1');
try {
const address = app.getHttpServer().address() as AddressInfo;
const previousAccepted = await requestJson(
address,
'POST',
'/v1/proposals',
proposalRequest(randomUUID()),
signedContextHeaders({
workspaceId,
actorId,
method: 'POST',
path: '/v1/proposals',
keyId: PREVIOUS_KEY_ID,
secret: PREVIOUS_KEY_SECRET,
}),
);
expect(previousAccepted.statusCode).toBe(201);

delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID;
delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET;
const retiredRejected = await requestJson(
address,
'POST',
'/v1/proposals',
proposalRequest(randomUUID()),
signedContextHeaders({
workspaceId,
actorId,
method: 'POST',
path: '/v1/proposals',
keyId: PREVIOUS_KEY_ID,
secret: PREVIOUS_KEY_SECRET,
}),
);
expect(retiredRejected).toMatchObject({
statusCode: 401,
body: { code: 'invalid_gateway_context' },
});

const activeAccepted = await requestJson(
address,
'POST',
'/v1/proposals',
proposalRequest(randomUUID()),
signedContextHeaders({
workspaceId,
actorId,
method: 'POST',
path: '/v1/proposals',
}),
);
expect(activeAccepted.statusCode).toBe(201);
} finally {
await app.close();
process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID;
process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET;
delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID;
delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET;
}
});

""".replace(' ', '')
replace_once(marker, rotation_test + marker, 'rotation lifecycle test')
path.write_text(source, encoding='utf-8')
PY

- name: Format and commit integration evidence
run: |
set -Eeuo pipefail
pnpm exec prettier --single-quote --write \
apps/ai-service/src/proposal-audit-http.integration.test.ts
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add apps/ai-service/src/proposal-audit-http.integration.test.ts
git diff --cached --check
git commit -m 'test(ai): prove zero-downtime gateway key rotation'
git push origin HEAD:feat/ai-gateway-key-rotation

- name: Run complete AI service evidence with PostgreSQL
run: |
pnpm --filter @life-os/ai-service lint
pnpm --filter @life-os/ai-service test
Loading
Loading