Compare commits
62
Commits
32e10c710b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b53e296ef5 | ||
|
|
fdbf811808 | ||
|
|
1fba43272a | ||
|
|
18e13ca80c | ||
|
|
8c021c134f | ||
|
|
29d26bd036 | ||
|
|
4777a3abd5 | ||
|
|
46f64d37b6 | ||
|
|
7ca6cb8458 | ||
|
|
57068b61cd | ||
|
|
d9924a95b9 | ||
|
|
d09b6220ee | ||
|
|
c61b9832dd | ||
|
|
b7c7705266 | ||
|
|
25d9241b51 | ||
|
|
9089f4a257 | ||
|
|
92e1f58320 | ||
|
|
dc08eefce6 | ||
|
|
bc40a737a8 | ||
|
|
dd84dff055 | ||
|
|
b47e2588e9 | ||
|
|
1998e87309 | ||
|
|
9ac2fe47cf | ||
|
|
aaf9e0ce97 | ||
|
|
3acc08c81d | ||
|
|
d98705cc9b | ||
|
|
11406e3ec6 | ||
|
|
f48cc878aa | ||
|
|
1bd9facd3f | ||
|
|
fee4dc3a78 | ||
|
|
5859f84034 | ||
|
|
4de3e1cc2c | ||
|
|
6186534ac8 | ||
|
|
3f440dd3c9 | ||
|
|
65a4987cad | ||
|
|
f1b473ffa8 | ||
|
|
a434cbe60c | ||
|
|
0b3be3587b | ||
|
|
7e78d75f85 | ||
|
|
37918bd411 | ||
|
|
568e46cd05 | ||
|
|
36dd018a4a | ||
|
|
6e12b2f3b7 | ||
|
|
fe4acec191 | ||
|
|
27cbeca0ed | ||
|
|
6b76110b76 | ||
|
|
efb0ccdc7e | ||
|
|
4eb754e7c8 | ||
|
|
5a67b252aa | ||
|
|
7b3aff30d8 | ||
|
|
8b80e55e6c | ||
|
|
6ea45c05fa | ||
|
|
338ea8761d | ||
|
|
b1656c430f | ||
|
|
6722665f5c | ||
|
|
ed31748102 | ||
|
|
63b04d26a9 | ||
|
|
e8ed35e35f | ||
|
|
2e868d86ff | ||
|
|
75faef50b8 | ||
|
|
f5eb2fd663 | ||
|
|
548d19d9ea |
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: deploy-production
|
||||||
|
description: Deploy SHD.build main to production through Gitea Actions and verify the live application. Use for a production release or a failed production deployment.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deploy Production
|
||||||
|
|
||||||
|
Production deploys from `main` through `Validate and deploy production`. The workflow builds the candidate while the current app remains online, stops only the SHD app and scheduler, creates a database backup, migrates, then health-checks the replacement.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Confirm that the commit passed `npm run check` and `npm run build`, then push it to `main` through `git@git.jb.codes:jb/shd.build.git`.
|
||||||
|
2. Open the Gitea production workflow for that commit. Confirm `validate` and `deploy` both succeed.
|
||||||
|
3. Verify `https://shd.build/api/health`. Confirm `ready` is true and the database check reports `ok`.
|
||||||
|
4. When host access is required, use `ssh f302` and inspect `/opt/shd-build-production` with its `.env.deploy` and `compose.production.yml` files. Confirm the app is healthy and the scheduler is running.
|
||||||
|
|
||||||
|
## Migration Failure
|
||||||
|
|
||||||
|
1. Open the failed `Deploy production` log and identify the failing statement and target schema state.
|
||||||
|
2. Do not patch the production database by hand. Fix the idempotent migration in source, validate it locally, commit it, and push a new commit.
|
||||||
|
3. The next workflow deployment creates a new production backup before it retries the migration. Confirm the recovery deployment reaches a healthy app state.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Do not use `docker compose down --volumes` or delete production volumes.
|
||||||
|
- Do not run a second manual deployment while a Gitea deployment is active.
|
||||||
|
- Routine deployment preserves production data. Use `replace-production-database` only with explicit approval for a full clone.
|
||||||
|
- Report the commit SHA, workflow result, production health response, and backup path from any migration attempt.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: deploy-staging
|
||||||
|
description: Deploy SHD.build main to staging, inspect Gitea Actions results, and verify the staged application. Use for a staging release or a failed staging deployment.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deploy Staging
|
||||||
|
|
||||||
|
Push the intended commit to `main` over the configured SSH remote. The `Validate and deploy staging` workflow builds the image, creates a database backup, applies migrations, and starts the app and scheduler.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Inspect `git status`, the staged diff, recent commits, and the current branch. Do not commit generated caches such as Python `__pycache__` directories.
|
||||||
|
2. Run the relevant tests. Source changes require `npm run check` and `npm run build` before deployment.
|
||||||
|
3. Commit only the intended files, then push with `git push git@git.jb.codes:jb/shd.build.git main`.
|
||||||
|
4. Open Gitea Actions and inspect the workflow run for the pushed commit. Wait for both `validate` and `deploy` to succeed.
|
||||||
|
5. If the deploy job fails, open the failed step output. Do not retry a database migration without reading its error and checking the generated SQL against the target schema.
|
||||||
|
6. Verify `https://stage.shd.build/api/health` through the staging access layer or from the deployment host. Confirm the app container reports healthy and the scheduler runs.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- The workflow owns the staging database lifecycle. Do not run `docker compose down --volumes`.
|
||||||
|
- A failed migration stops the application. Fix and test the migration, commit the repair, and push a new commit. The workflow creates another backup before retrying.
|
||||||
|
- Keep staging data intact during routine deployment. Use the explicit bootstrap or ISAC-import workflows for data replacement.
|
||||||
|
- Report the workflow run, commit SHA, health result, and any backup path printed by the deployment logs.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: import-talent-screenshot
|
||||||
|
description: Extract a Division Resurgence talent from one or more screenshots, isolate its icon, reconcile Tier I and Tier II wording and values, create any missing mechanic prerequisites, and enter the reviewed talent through SHD.build ISAC. Use when the user supplies talent screenshots or asks to import, create, model, or enter a pictured talent.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Import Talent Screenshot
|
||||||
|
|
||||||
|
Create a complete, modeled talent from screenshots. Submit mutations to the authenticated ISAC server action so validation, permissions, relationships, icon handling, and audit logging remain intact. Prefer direct requests over operating the editor UI.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Inspect every supplied screenshot at original detail.
|
||||||
|
2. Extract the displayed title, description, highlighted values, thresholds, durations, stacks, and other mechanically meaningful wording.
|
||||||
|
3. Classify a title ending in `II` as Tier II. Classify the matching title without `II` as Tier I. Store one talent under the base title without the tier suffix. Do not strip a numeral that is genuinely part of the base name; ask when the screenshots do not establish the distinction.
|
||||||
|
4. Reconcile both tiers into one shared description template. Replace tier-varying values with numbered placeholders in reading order. Use `{duration}` and `{max_stacks}` only when the mechanic defines them.
|
||||||
|
5. Ask the user for exactly one required choice before entry: `Weapon Talent 1`, `Weapon Talent 2`, `Backpack`, or `Body Armor`. Do not infer this choice from the icon or talent wording.
|
||||||
|
6. Ask other questions only when missing evidence would materially change stored data or combat behavior. State the unresolved alternatives precisely.
|
||||||
|
7. Search the Talent registry for the same base name and category. Update only when the user explicitly requests an update; otherwise stop on a duplicate.
|
||||||
|
8. Search active attributes, supported mechanic recipes, runtime events, outcomes, scopes, resources, and state behavior before creating prerequisites. Reuse semantically identical records.
|
||||||
|
9. Implement missing mechanic functionality when the screenshot establishes behavior that the runtime cannot yet represent. Extend the shared mechanic types, validation, persistence, ISAC editor, runtime execution, pseudo-script, reference UI, schema migration, and focused tests as required. Preserve generic semantics; do not add talent-name checks or a one-off recipe when a reusable event, outcome, scope, resource, or state rule fits. Follow the repository's database-preservation procedure before applying a schema migration.
|
||||||
|
10. Create a missing attribute or other data requirement through its authenticated ISAC server action only when its name, unit, type, category, and calculation semantics are established by the screenshots or project data. Ask for any material field that cannot be derived safely.
|
||||||
|
11. Model the mechanic with the narrowest supported recipe. Prefer health-threshold bands for wording such as “while below N% Health.” Treat those bands as mutually exclusive strongest-matching ranges unless the source explicitly says effects stack.
|
||||||
|
12. Set each numbered outcome's Tier I and Tier II values, phase, scope, basis, and attribute. Mark the mechanic `READY` only after the full behavior is executable and tested. Use `ASSUMPTIONS` or `CAPTURE ONLY` only when source evidence is incomplete, not merely because runtime functionality is missing.
|
||||||
|
13. Isolate the icon from the screenshot. Preserve the pictured silhouette rather than redesigning it. Remove text and UI background, center it on a square transparent canvas, retain antialiased edges, and validate it at small display size. Use the image-generation editing workflow only when a faithful crop and local cleanup are insufficient.
|
||||||
|
14. Build one `multipart/form-data` payload matching the current talent form contract, including the prepared icon and serialized mechanic program. Submit it directly to the authenticated `/isac/talents?/saveTalent` server action. Use browser-side `fetch` when the browser holds the authenticated session. Do not open, populate, click, or submit the talent editor. Do not use the editor's file input to stage the icon. Make the icon available to the browser-side request without interacting with the editor, then attach it to the `FormData` as a `File`. Inspect the current route source before relying on a field name or action path.
|
||||||
|
15. Read the saved registry row back. Verify the name, category, icon, both rendered tier descriptions, modeled state, thresholds, values, and any newly implemented runtime behavior. Confirm the successful `game_data` audit event when audit access is available.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Never invent values hidden by cropping, blur, overlays, or missing tiers.
|
||||||
|
- Preserve strict wording: “below 25%” means `< 25`, not `<= 25`.
|
||||||
|
- Do not add tier values together. For ordered health thresholds, select the value for the current non-overlapping band unless the source states that effects accumulate.
|
||||||
|
- Do not create a near-duplicate attribute because of capitalization, spacing, or a synonymous label.
|
||||||
|
- Do not expose credentials, session data, or personal data in screenshots or audit checks.
|
||||||
|
- Never write talent, tier, effect, mechanic, icon, or audit rows directly to the database. Never call lower-level persistence helpers to bypass the route action.
|
||||||
|
- Do not manually create an audit event. A successful server action must create it.
|
||||||
|
- Never use the talent editor UI for an import. If direct submission cannot preserve the authenticated session, file upload, or current form contract, stop and report the blocker.
|
||||||
|
- Preview extracted fields and planned mechanics before the first external mutation when the user has not already approved entry.
|
||||||
|
|
||||||
|
## Project Reference
|
||||||
|
|
||||||
|
Read [references/shd-talent-entry.md](references/shd-talent-entry.md) before entering a talent. Re-check the referenced source files when the application schema or editor changes.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Import Talent Screenshot"
|
||||||
|
short_description: "Create modeled talents from game screenshots"
|
||||||
|
default_prompt: "Use $import-talent-screenshot to extract and enter a talent from my screenshots."
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# SHD Talent Entry Reference
|
||||||
|
|
||||||
|
## Canonical surfaces
|
||||||
|
|
||||||
|
- Talent editor: `src/routes/isac/talents/+page.svelte`
|
||||||
|
- Talent action and validation: `src/routes/isac/talents/+page.server.ts`
|
||||||
|
- Mechanic editor: `src/lib/components/isac/MechanicProgramEditor.svelte`
|
||||||
|
- Mechanic draft types: `src/lib/calculations/mechanic-editor.ts`
|
||||||
|
- Mechanic runtime and validation: `src/lib/calculations/mechanic.ts`
|
||||||
|
- Mechanic persistence: `src/lib/server/conditional-effects.ts`
|
||||||
|
- Supported recipes: `src/lib/calculations/mechanic-recipes.ts`
|
||||||
|
- Attribute registry and actions: `src/routes/isac/attributes/`
|
||||||
|
- Icon validation and persistence: `src/lib/server/game-data-icons.ts`
|
||||||
|
|
||||||
|
Treat these files as the current authority. Inspect them before acting when fields or behavior may have changed.
|
||||||
|
|
||||||
|
When a pictured mechanic is not representable, add the smallest reusable runtime capability and its validation, persistence, editor controls, migration, reference entry, and tests before entering the talent. A missing runtime feature is an implementation task, not a reason by itself to leave a talent under assumptions.
|
||||||
|
|
||||||
|
## Request path
|
||||||
|
|
||||||
|
Prefer a direct authenticated request to the SvelteKit talent action over editor interaction:
|
||||||
|
|
||||||
|
- Load or inspect `/isac/talents` to obtain current registry data and confirm the authenticated session.
|
||||||
|
- Construct `FormData` from the current contract in `+page.svelte` and `+page.server.ts`.
|
||||||
|
- Include the icon as the `icon` file part and the complete mechanic program as serialized `conditionalRule`.
|
||||||
|
- Submit one `POST` request to `/isac/talents?/saveTalent` with an accepted SvelteKit action response type.
|
||||||
|
- Parse the action result, then query the registry and audit surfaces for readback.
|
||||||
|
|
||||||
|
Do not treat the action path as a public, version-stable REST API. Re-check the route implementation when it changes. Do not insert into the embedded database or call persistence utilities directly.
|
||||||
|
|
||||||
|
## Talent form
|
||||||
|
|
||||||
|
The user-facing categories are:
|
||||||
|
|
||||||
|
- `Weapon Talent 1`
|
||||||
|
- `Weapon Talent 2`
|
||||||
|
- `Backpack`
|
||||||
|
- `Body Armor`
|
||||||
|
|
||||||
|
Talent descriptions use one shared template. Number tier-varying outcomes as `{1}`, `{2}`, and so on in reading order. The action derives Tier I and Tier II descriptions from each outcome's variant values.
|
||||||
|
|
||||||
|
## Mechanic entry
|
||||||
|
|
||||||
|
Use the `health_threshold_bands` recipe for non-stacking current-health ranges. Enter thresholds from strongest to weakest in the editor and assign one outcome to each band. Verify exact boundary behavior after saving.
|
||||||
|
|
||||||
|
For the Spirited example:
|
||||||
|
|
||||||
|
- Base name: `Spirited`
|
||||||
|
- Tier I: 9% below 75% Health; 12% below 50%; 15% below 25%
|
||||||
|
- Tier II: 12.60% below 75% Health; 16.80% below 50%; 21% below 25%
|
||||||
|
- Shared template: `Grants +{1} Damage Reduction while below 75% Health. Grants +{2} Damage Reduction while below 50% Health. Grants +{3} Damage Reduction while below 25% Health.`
|
||||||
|
- Attribute: `Damage Reduction`, unit `%`
|
||||||
|
- Scope: self
|
||||||
|
- Phase: while active
|
||||||
|
- Basis: flat percentage points
|
||||||
|
- Bands: `[50,75)`, `[25,50)`, and `[0,25)`; no effect at or above 75%
|
||||||
|
|
||||||
|
Confirm that `Damage Reduction` already exists before creating it. Confirm the category/slot with the user before saving.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
name: import-weapon-screenshot
|
||||||
|
description: Extract a Division Resurgence weapon from screenshots, match pictured talent icons to existing SHD.build talents, prepare its weapon image, collect missing tier and augmentation values, and enter the reviewed weapon through ISAC. Use when the user supplies weapon-detail, blueprint, crafting, talent-grid, or weapon-stat screenshots and asks to import, create, model, or enter the weapon.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Import Weapon Screenshot
|
||||||
|
|
||||||
|
Create a complete weapon record from screenshots without guessing hidden configuration. Submit mutations to the authenticated ISAC server action so validation, relationships, and audit logging remain intact. Prefer direct requests over operating the editor UI.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Inspect every screenshot at original detail. Extract the weapon name, category, native tier, damage type, fixed handling values, visible damage range, talent icons, and augmentation evidence.
|
||||||
|
2. Search the Weapon registry by normalized name. Stop on a duplicate unless the user explicitly requests an update. Treat named variants such as `First Wave` as distinct weapons.
|
||||||
|
3. Reconcile screenshot labels with project vocabulary:
|
||||||
|
- `Piercing Damage` → `Pierce`
|
||||||
|
- `Blast Damage` → `Blast`
|
||||||
|
- `Shredding Damage` → `Shredding`
|
||||||
|
- Preserve the displayed native tier.
|
||||||
|
4. Ask directly for missing damage-range numbers for every tier at or above the native tier. Do not request extra screenshots when typed minimum and maximum values are faster.
|
||||||
|
5. Read each Talent 1 and Talent 2 grid as a complete, unordered set. These weapon screenshots show every available talent twice: once for Tier I and once for Tier II. Group identical icons across the entire grid, resolve each two-icon group against active talents in the corresponding ISAC slot, and store the matching talent once. Compare silhouettes and internal details only. Tier variants may appear in different rows, separated by other talents, or in any order; never use adjacency, sequence, or grid position to pair them.
|
||||||
|
6. For an ambiguous or unmatched talent icon, identify its slot and screenshot position and request a close-up showing that talent's icon, name, and description. After the user supplies it, invoke `import-talent-screenshot` to create and model the missing talent, then resume the weapon import.
|
||||||
|
7. Never infer talents that are cropped below the screenshot. Ask for another talent-grid screenshot when the visible grid may continue.
|
||||||
|
8. Ask the user to type Tactical Augmentation 01 as one or two `Attribute = value` pairs. Reuse exact active attributes and stop on ambiguous names.
|
||||||
|
9. Treat weapon mod slots as unknown unless the user supplies explicit evidence. Save the weapon with no mod-slot configuration; expect ISAC to display `CONFIG REQUIRED`. Do not borrow slots from a related weapon.
|
||||||
|
10. Extract the weapon render from the screenshot. Preserve the pictured model, attachments, materials, and proportions. Remove UI, cursor, text, and background; center the weapon on a transparent canvas with useful padding. Use the image-generation editing workflow only when a faithful crop and local cleanup are insufficient.
|
||||||
|
11. Search for every prerequisite before creating it. If the current schema or runtime cannot represent established weapon behavior, implement the smallest reusable capability, including validation, persistence, editor support, migrations, reference material, and tests. Follow database-preservation rules before applying schema changes.
|
||||||
|
12. Preview the extracted fields, resolved talents, unresolved evidence, and planned ISAC payload before the first mutation unless the user has already approved entry.
|
||||||
|
13. Build one `multipart/form-data` payload matching the current weapon form contract and submit it directly to the authenticated `/isac/weapons?/save` server action. Use browser-side `fetch` when the authenticated session is held by the browser; do not click through the editor merely to populate fields. Inspect the current route source before relying on a stale field name or action path.
|
||||||
|
14. Read the saved registry row back. Verify identity, category, native tier, damage type, fixed attributes, all required tier ranges, talent pools, augmentation effects, image, and expected configuration state. Confirm the `game_data` audit event when audit access is available.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Do not infer tier damage ranges by scaling another tier or related weapon.
|
||||||
|
- Do not match a talent from resemblance alone when more than one active icon is plausible.
|
||||||
|
- Every talent in these weapon talent grids appears twice, once per tier. Deduplicate identical icons across the complete slot grid into one weapon talent option, regardless of their order or position.
|
||||||
|
- Do not assume the screenshot shows the complete talent list when the panel continues beyond the frame.
|
||||||
|
- Do not infer weapon mod slots.
|
||||||
|
- Do not create a near-duplicate talent or attribute because of spelling, capitalization, or synonymous labels.
|
||||||
|
- Preserve numeric precision exactly as displayed or typed.
|
||||||
|
- Never write weapon, tier, talent-option, augmentation, mod-slot, image, or audit rows directly to the database. Never call lower-level persistence helpers to bypass the route action.
|
||||||
|
- Do not manually create an audit event. A successful server action must create it.
|
||||||
|
- Use the UI only when direct submission cannot preserve an authenticated session, file upload, or current form contract.
|
||||||
|
- Keep authored database data intact and preserve unrelated worktree changes.
|
||||||
|
|
||||||
|
## Project Reference
|
||||||
|
|
||||||
|
Read [references/shd-weapon-entry.md](references/shd-weapon-entry.md) before entering a weapon. Re-check the referenced source files when the editor or schema changes.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Import Weapon Screenshot"
|
||||||
|
short_description: "Import pictured weapons into SHD.build"
|
||||||
|
default_prompt: "Use $import-weapon-screenshot to extract and enter this pictured weapon."
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# SHD Weapon Entry Reference
|
||||||
|
|
||||||
|
## Canonical surfaces
|
||||||
|
|
||||||
|
- Weapon editor and client payload: `src/routes/isac/weapons/+page.svelte`
|
||||||
|
- Weapon action, validation, persistence, and audit snapshot: `src/routes/isac/weapons/+page.server.ts`
|
||||||
|
- Weapon categories: `src/lib/weapons/category-bonuses.ts`
|
||||||
|
- Weapon mod-slot rules: `src/lib/weapons/mod-slots.ts`
|
||||||
|
- Talent editor and classification: `src/routes/isac/talents/`
|
||||||
|
- Talent screenshot workflow: `../../import-talent-screenshot/SKILL.md`
|
||||||
|
- Icon validation and persistence: `src/lib/server/game-data-icons.ts`
|
||||||
|
|
||||||
|
Inspect these files before entry when fields or behavior may have changed.
|
||||||
|
|
||||||
|
## Request path
|
||||||
|
|
||||||
|
Prefer a direct authenticated request to the SvelteKit weapon action over editor interaction:
|
||||||
|
|
||||||
|
- Load or inspect `/isac/weapons` to obtain current talent, attribute, category, and registry data and confirm the authenticated session.
|
||||||
|
- Construct `FormData` from the current contract in `+page.svelte` and `+page.server.ts`.
|
||||||
|
- Preserve repeated relationship fields such as talent IDs and serialize structured fields such as mod slots exactly as the action expects.
|
||||||
|
- Submit one `POST` request to `/isac/weapons?/save` with an accepted SvelteKit action response type.
|
||||||
|
- Parse the action result, then query the registry and audit surfaces for readback.
|
||||||
|
|
||||||
|
Do not treat the action path as a public, version-stable REST API. Re-check the route implementation when it changes. Do not insert into the embedded database or call persistence utilities directly.
|
||||||
|
|
||||||
|
## Required standard-weapon data
|
||||||
|
|
||||||
|
- Unique name
|
||||||
|
- Category: `Shotgun`, `Rifle`, `MMR`, `AR`, `LMG`, or `SMG`
|
||||||
|
- Native tier: `T0`, `T1`, or `T2`
|
||||||
|
- Damage type: `Blast`, `Pierce`, or `Shredding`
|
||||||
|
- RPM, magazine size, reload seconds, accuracy, and stability
|
||||||
|
- Damage minimum and maximum for every tier at or above the native tier
|
||||||
|
- At least one existing Talent 1 and Talent 2 option
|
||||||
|
- One or two unique Tactical Augmentation 01 attribute/value pairs
|
||||||
|
|
||||||
|
Weapon mod slots are optional evidence. An otherwise valid weapon with unknown slots may be saved and will remain `CONFIG REQUIRED`.
|
||||||
|
|
||||||
|
## Talent icon matching
|
||||||
|
|
||||||
|
Query the current weapon or talent registry data to obtain active Talent 1 and Talent 2 icon sets. Match within the correct slot only. Use a visual contact sheet when the set is large; opening the weapon editor is unnecessary.
|
||||||
|
|
||||||
|
These weapon talent grids display every talent twice: once for Tier I and once for Tier II. Treat each slot grid as an unordered set, group identical icons across the complete grid, and store each matching talent ID once. Tier variants may occupy different rows, have other talents between them, or appear in either order. Pair by icon artwork only. If a two-icon group has no unique registry match, request a close-up with its name and description and run `import-talent-screenshot` before continuing.
|
||||||
|
|
||||||
|
## Image preparation
|
||||||
|
|
||||||
|
Use the supplied weapon render as the source of truth. Remove the surrounding game interface and background without redesigning the weapon. Save the final asset in the workspace, include it in the server-action payload when the current weapon contract supports an image, and verify the resulting image URL.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
name: replace-production-database
|
||||||
|
description:
|
||||||
|
Replace the populated SHD.build production database with a local full
|
||||||
|
bootstrap bundle after verified backups. Use only when the user explicitly
|
||||||
|
authorizes replacement of all production data.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Replace Production Database
|
||||||
|
|
||||||
|
This procedure overwrites production users, sessions, builds, guides, audits,
|
||||||
|
proposals, settings, and source data. Obtain explicit approval for a full
|
||||||
|
replacement. Use the ISAC import flow when the request only concerns canonical
|
||||||
|
game data.
|
||||||
|
|
||||||
|
## Export
|
||||||
|
|
||||||
|
1. Stop only this repository's local development server. The embedded database
|
||||||
|
must be closed during export.
|
||||||
|
2. Run `npm run production:bootstrap:export`. Record the generated bundle
|
||||||
|
beneath `data/backups/`; it includes private account data and uploaded
|
||||||
|
assets.
|
||||||
|
3. Transfer the bundle to `f302` over SSH. Keep it outside Git and confirm the
|
||||||
|
remote copy is nonempty.
|
||||||
|
|
||||||
|
## Replace
|
||||||
|
|
||||||
|
1. Confirm the current production deployment is healthy and no Gitea production
|
||||||
|
deployment is running.
|
||||||
|
2. On `f302`, stop only the production `app` and `scheduler` services from
|
||||||
|
`/opt/shd-build-production`.
|
||||||
|
3. Run `node scripts/db-backup.mjs` through the production Compose service.
|
||||||
|
Record and retain the JSON backup path before any destructive work.
|
||||||
|
4. Reset the production database file and its embedded-database support files.
|
||||||
|
Keep the persistent volume and existing upload directories; the import writes
|
||||||
|
the local avatar and managed-image files without deleting retained files.
|
||||||
|
5. Run `node scripts/db-migrate.mjs` through the production Compose service to
|
||||||
|
create the current empty schema.
|
||||||
|
6. Run `/opt/shd-build-production/bootstrap-production.sh <bundle-path>`. The
|
||||||
|
script stages the bundle in the private data volume, assigns it to the
|
||||||
|
runtime UID, runs `environment-bootstrap-import.mjs` with the required
|
||||||
|
confirmation, removes the staged copy, and restarts both services.
|
||||||
|
7. Wait for app health, then remove the remote transfer bundle.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
1. Confirm the importer reports the expected row, avatar, and managed-image
|
||||||
|
counts.
|
||||||
|
2. Confirm `https://shd.build/api/health` returns `ready: true` with an `ok`
|
||||||
|
database check.
|
||||||
|
3. Confirm the production app container is healthy and its scheduler is running.
|
||||||
|
4. Restart the local development server and verify it accepts an HTTP request.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Never replace a populated production database without explicit user approval.
|
||||||
|
- Never delete the production Compose volume.
|
||||||
|
- Do not replace the database while the app or scheduler can write to it.
|
||||||
|
- Keep the verified pre-replacement backup. It is the recovery source if the
|
||||||
|
import or restart fails.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.agents
|
||||||
|
.codex
|
||||||
|
.svelte-kit
|
||||||
|
.vscode
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
|
data
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.env.keys
|
||||||
|
*.log
|
||||||
|
docs
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
#/-------------------[DOTENV_PUBLIC_KEY]--------------------/
|
|
||||||
#/ public-key encryption for .env files /
|
|
||||||
#/ [how it works](https://dotenvx.com/encryption) /
|
|
||||||
#/----------------------------------------------------------/
|
|
||||||
DOTENV_PUBLIC_KEY_DEVELOPMENT="038fa4f9f7abd799fabebc69cb4d6a42a2ea361c3ec799580ddd213bcafac5c167"
|
|
||||||
|
|
||||||
# .env.development
|
|
||||||
TDR_DATABASE_PATH=encrypted:BNTRek+Cz9NI9Qasv0a+BWUNs5u5qTESE+1U7fybMS3NJLQkY8WR/eQ0v8/ZoiiHLSM/EGum5NRNC/iRSYIjkjFTvfC4xFnUXALvy3zM2Iw4AezGWdmOZ6RI5csNtGQKQ0idOicuA1YN3FpcDdOSSZXTOyVniwFduWcAZTuBPTo=
|
|
||||||
DISCORD_CLIENT_ID=encrypted:BDuk9O2rhgVwi6/NqnG1+n2Jeb/A6ciQwNAdY3Ih7R08DhTF4g2d9ZKpg+OzmIL7BeZF3ikkedlfljxn8xbW8f9YGrrfaLLdTDVClyT+scyfcKeaK6uunQVvipG5UwhotEd3VS8T9IcQyNDVq9y9SEgIjyo=
|
|
||||||
DISCORD_CLIENT_SECRET=encrypted:BE3+3NfbuQpjbpc8Pyl0e5YEW478dOKdi5cWuwHjJ5L2z/X+1hJ+B6ag4oXwPBt20t12qWtv5IYPJGMb+Uzk6hZLazqkHxl+DUozeNMAOOrNHESPiTwH7fpI0vf4oVTWH+j4iYMRUlmLkjzOICTmAL+ljOJgi5dTqHx05unXGqxZ
|
|
||||||
DISCORD_REDIRECT_URI=encrypted:BEWUNH2YlygQ23TBiXj9lgJ7hijU13YJhiBYVM1VEHHM3s8ryqWJ+hz2bJ0EyGHg3XU9UBPDhmgLkafULYzY/KgOLmSMq6TkrYq1e7Qec+K70ZyqoNp7L1s9C0EK//dpkE1iMvd92bhLPOeS8sOhFN4dyYbK2vPGRdi7AKxFwiU/jq7pbwkHeU5DQXU=
|
|
||||||
|
|
||||||
# Passwordless authentication
|
|
||||||
APP_ORIGIN=encrypted:BE7igk/cyE/Wv72Rc8A0O815n/uDyyaKdLwdonSjrc7rHWiwBifF8S6EQ5tUkH8i0eQsxLGxcAJTW0H9gfGw0Nftxl+1Mr2slGLZJxxiJ4Upp1CU3pLwIgcTg82ew8ewOvfYnT6mVLPMHO/02vomjOxYobLKdQ==
|
|
||||||
PASSKEY_RP_ID=encrypted:BNMQdCdZR9e47vjPfme83ujNlJqsmJgGeri8r+uuJ1MwHeWXuv8FKi+XHKKX8GaXvEdqD0mLSFyYvUMgrzJb/ZwEo/B77opQ67AXFtERz4qUKDc6XdOvFNRUvlPjPaO/eoyqr64WIEjG3w==
|
|
||||||
|
|
||||||
# Shared SMTP account for magic-link and email-verification delivery.
|
|
||||||
# Implicit TLS/SSL normally uses port 465. For STARTTLS on port 587, set
|
|
||||||
# SMTP_SECURE=false.
|
|
||||||
SMTP_HOST=encrypted:BGk17DA/BpNINXIl4d7LKTLi3IfAjvyk1OhP5yPnk6ZjE3FHSJl3ed6tQh5oPoUt8zovm+hyV2rD/Pae+axnWdxKXtUohp5HkqNqC1T4SEeVWpUsx5m/xXt8hxcwEQ/1HfzTcdx41wo/hR2ereZMFaX7sA==
|
|
||||||
SMTP_PORT=encrypted:BBzjlvppArJeJNKCeNyuf4+bX4ohICRycdb/BbZTryN114iZ1d6wyQr6Gre2Eg9fhTQqdIiDjjqpb9UWbjavutlHFZ0UarMUWaOBRfVd9+sqmXI8/vmA6Scp8z4MxteqB5+6jQ==
|
|
||||||
SMTP_SECURE=encrypted:BBMBvqEuiHmFAy+C56yr/g1kPQsVHu+xO5eVtHis4vdAHHsGVO/blfVMHDw0PAiHPFITHDrp7GjNnvQSq0AKgLK+Mn2/Iv7XC9oQedoplQFOY4kNuM77qWJur/H93DgtMckkfNB+
|
|
||||||
SMTP_USER=encrypted:BHmiGy4eBiIOP5PLYBsqomWDCc7DSFvmfXPyPtD6/Cj0KLQAWaVVQowQbsc0XuvuOhesEAcrMBtAD0+rsAmAhoHvdudqc7jPXk0IAFeeasAp8CWvPzd2Afb6tI/Z2xv5BxVsR6l4p1uh+SlK0lfskBGSdQ==
|
|
||||||
SMTP_PASSWORD=encrypted:BJ8mNgq9j9mmsptaEP8L47MbuB/l9jmkMGXco5ybw91a/vZFyZ5xLWTUxdVYYlHiBIAhjs1eEsx7dQgc4+5wmGFyNfDILod5Turp/OqDD8qV2c7wsKGzOrV6TCGELscoxiHg28YCZCriZnIs9Uw9yRE=
|
|
||||||
SMTP_FROM=encrypted:BGOoIMcTZ8Wknq81asUOHBtHsqKmUeVq8SDdvp+F5lWKwWxSos8cE/inhJk8gfVEp8cf6m8xVZQt+qUh0lscKA5LPpyixnFe1AyHRAlfqc+r2200492HxHrwKZfEzZ9RFMlN7omgC8ADAZ6eo5mvT/udqHWJpIQLJNSV5GFgjw==
|
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
|
# Infisical secret inventory. This file contains names and safe defaults only;
|
||||||
|
# the application does not load it. Store values under /app unless noted.
|
||||||
|
|
||||||
|
# Server-only OpenAI access for the permission-gated ISAC mechanic and
|
||||||
|
# calculation authoring assistants. Use a project-scoped, Responses-only restricted key.
|
||||||
|
OPENAI_MECHANIC_API_KEY=
|
||||||
|
OPENAI_MECHANIC_MODEL=gpt-5.6-terra
|
||||||
|
OPENAI_MECHANIC_REASONING_EFFORT=medium
|
||||||
|
OPENAI_MODERATION_API_KEY=
|
||||||
|
|
||||||
# Local path used by the embedded @tursodatabase/database engine.
|
# Local path used by the embedded @tursodatabase/database engine.
|
||||||
# Relative paths are resolved from the project directory. Development and
|
# Relative paths are resolved from the project directory. Development and
|
||||||
# production should always use different database files.
|
# production should always use different database files.
|
||||||
@@ -6,6 +16,10 @@ TDR_DATABASE_PATH=./data/shd-build-development.db
|
|||||||
# Persistent directory for normalized user-uploaded avatars.
|
# Persistent directory for normalized user-uploaded avatars.
|
||||||
TDR_AVATAR_STORAGE_PATH=./data/avatars
|
TDR_AVATAR_STORAGE_PATH=./data/avatars
|
||||||
|
|
||||||
|
# Persistent directory for game-data icons and managed library images, including
|
||||||
|
# weapons, gear, mods, talents, brands, protocols, Skill Chips, and Specializations.
|
||||||
|
TDR_GAME_ICON_STORAGE_PATH=./data/game-icons
|
||||||
|
|
||||||
# Optional fixed credentials used only when initializing or explicitly resetting
|
# Optional fixed credentials used only when initializing or explicitly resetting
|
||||||
# the two default accounts. Leave blank to generate secure temporary passwords.
|
# the two default accounts. Leave blank to generate secure temporary passwords.
|
||||||
DEFAULT_ADMIN_PASSWORD=
|
DEFAULT_ADMIN_PASSWORD=
|
||||||
@@ -15,11 +29,14 @@ DEFAULT_USER_PASSWORD=
|
|||||||
DISCORD_CLIENT_ID=
|
DISCORD_CLIENT_ID=
|
||||||
DISCORD_CLIENT_SECRET=
|
DISCORD_CLIENT_SECRET=
|
||||||
# Proxied development: https://dev.shd.build/auth/discord/callback
|
# Proxied development: https://dev.shd.build/auth/discord/callback
|
||||||
|
# Staging: https://stage.shd.build/auth/discord/callback
|
||||||
# Production: https://shd.build/auth/discord/callback
|
# Production: https://shd.build/auth/discord/callback
|
||||||
DISCORD_REDIRECT_URI=https://dev.shd.build/auth/discord/callback
|
DISCORD_REDIRECT_URI=https://dev.shd.build/auth/discord/callback
|
||||||
|
|
||||||
# Canonical application origin used in emailed authentication links.
|
# Canonical application origin used in emailed authentication links.
|
||||||
APP_ORIGIN=https://dev.shd.build
|
APP_ORIGIN=https://dev.shd.build
|
||||||
|
# SvelteKit adapter-node canonical origin when deployed behind a reverse proxy.
|
||||||
|
ORIGIN=https://dev.shd.build
|
||||||
# Use shd.build for both production and dev.shd.build. Use localhost only when
|
# Use shd.build for both production and dev.shd.build. Use localhost only when
|
||||||
# accessing development directly at http://localhost:5173.
|
# accessing development directly at http://localhost:5173.
|
||||||
PASSKEY_RP_ID=shd.build
|
PASSKEY_RP_ID=shd.build
|
||||||
@@ -32,3 +49,15 @@ SMTP_SECURE=false
|
|||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM=SHD.build <no-reply@shd.build>
|
SMTP_FROM=SHD.build <no-reply@shd.build>
|
||||||
|
|
||||||
|
# Cloudflare Turnstile challenge keys for anonymous Guide activity.
|
||||||
|
TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
|
||||||
|
REMOVE_BG_API_KEY=
|
||||||
|
REMOVE_BG_LOW_CREDIT_THRESHOLD=5
|
||||||
|
REMOVE_BG_LOW_FREE_CALL_THRESHOLD=10
|
||||||
|
REMOVE_BG_CREDIT_CHECK_HOURS=6
|
||||||
|
|
||||||
|
# /shared/scheduler
|
||||||
|
SCHEDULER_TOKEN=
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
#/-------------------[DOTENV_PUBLIC_KEY]--------------------/
|
|
||||||
#/ public-key encryption for .env files /
|
|
||||||
#/ [how it works](https://dotenvx.com/encryption) /
|
|
||||||
#/----------------------------------------------------------/
|
|
||||||
DOTENV_PUBLIC_KEY_PRODUCTION="034d799df86cb16852742f1b2a42c69dde86396ddce96448c429c6ba68034a331d"
|
|
||||||
|
|
||||||
# .env.production
|
|
||||||
TDR_DATABASE_PATH=encrypted:BE4lCm2/hLx3wEPCPvmIWHEPHS2oVLZHcWS6e4FmRwwkSR7GxaifHgccy32IInzWXiUp8P8OnZS6MYJn5X04wKtWV0y4FfbiCGs0e8YpM1Fo513hd1LmB6uDZo5pkacJV07KpG8NV01RveoHhe/ANN/UVwStFc7eZj85G4cu6A==
|
|
||||||
DISCORD_CLIENT_ID=encrypted:BK2ZUIggBGYBCU3zEaSOmo2mxqiWnOKtdKxK7FWTw56bG3xFoXtmSZ/vyz+OF/pLd6ciS/WO1nAxC+FND4mLqdZfm99MrWacQj79///zB+vsouDdwg3zqS76dxQ1zES4lPTzk+fhRqHe/EsqQK4sweax4fc=
|
|
||||||
DISCORD_CLIENT_SECRET=encrypted:BAwObVwDUb5HMWun2WUYpm3zrqFQHegE1KQnfq5I9tglZQXl6/XxkwHtNQFmoU0WgpsJERT1N+yO+0CtBzhdpufIHyXqjCj8uYD70wJvXY2BqEkth0JgKcMi7lO7gSgtybnzUGTDS0xLqjpBgw9lkl0rCun7axH/gkrd/rUEXLdF
|
|
||||||
DISCORD_REDIRECT_URI=encrypted:BGgy7YS2iU62+mSDlUDNwvZhE39Pr2o2kpnFJCg/16MaQK/VoZVyICSuLBuFE0snAUb8KIvcnklusqao8TU1izHeTS+rvBMLvRVzcyHDTZUynrmn4UCzG5w4phiTVi2OhaZvw8hQSkcvoV57mVtBo7LNEAuVEhuv5WAeOHOX9oaV4qYfKVviRQ==
|
|
||||||
|
|
||||||
# Passwordless authentication
|
|
||||||
APP_ORIGIN=encrypted:BDJyyjEXFeEjdJiHOGRlxpiWkbzFJBZnM6w1VAb8m4YRW+c0Jvg4kl1XzaRtf63TLjfNWVxxwhCBwJ9s5qeP2+O7FvbgKKxoEXeFyP+gBUDsXss8volhd7toPaF9sjdAtpXY5L0M7SVYOct+FQCHAoyX
|
|
||||||
PASSKEY_RP_ID=encrypted:BKvPDWDsTEwhAtFAsKENg85D4Klo+Ipp6KnZ0EZ1Nlyi6ikuswRqDFrqYtlleBhzwAHhl7/uD3P6e2cyaNvffokcwQQQb8CXhCVRyGFgy1htvpFXhMmdesMfuqoVZ87wb5ngDlHKKV3E2A==
|
|
||||||
|
|
||||||
# Shared SMTP account for magic-link and email-verification delivery.
|
|
||||||
# Implicit TLS/SSL normally uses port 465. For STARTTLS on port 587, set
|
|
||||||
# SMTP_SECURE=false.
|
|
||||||
SMTP_HOST=encrypted:BNKsoY8sz2Kf1ZaRtE1pU7JQM36Qj+KADK23R+pwVYV3RmeO6OP6AtGVGjDoHoPAqCsoSLPLM0INyX9XFdEJP+DtN0EVVoHgXt4+fAd1kXDBZTwZ+NEgFP6E6h8V5gZdMhar6hrnWecGfrFzs2c/1+e5Kg==
|
|
||||||
SMTP_PORT=encrypted:BDd5baDM0FWRaEDV/5s94/J8a2Ll9ZxLhBy5NVytdKXPbSq40E9UPKHPNS9vqet69Qceq7rzXgLmcdiqkqp0HdlocJUWzA5YIYcY5Er31o7nau7SKAk0KfnzHA6VVsvRDWxo/w==
|
|
||||||
SMTP_SECURE=encrypted:BPH6vl2VFiVF0m1sE5Ybt8rrM2TjkgEO5pKnu+5P++fiLkTnEr3bp5tAPbhqWAXpmgmXt2SB2KK/j9hH4Ih0/1cGNvSrqee5FNpwHKncrxrVRzdwSuIgYViziwCrReKXxR+uhzVD
|
|
||||||
SMTP_USER=encrypted:BKWljaiPijrWfVy5QHxWC83SCzs/MUlzFnI0i+CG19ZSfJ3dUqIdnACqjkpuYI5T/tO1AXB2kOptDc6M7foGdUZ1uDHzTWVOUl5yt0kx6rlAPExuRD9PmOdWvH/CzDQvn6MblJumUtZ5XgNTXG6RgZBqGg==
|
|
||||||
SMTP_PASSWORD=encrypted:BO10G2D7osxIP5jZP9SQbMFMvcmKJ6Pd4jFzWa2oHnz7RjY5pbRX7r+jFddm0lZqgj6jVJnJiFPMxKSpuSKJUoHKgXL36z5LcmwUuILysyKJAWj5HoeDOGzsgASz0pezESsDoWeH1+0iWPbw6A0AmUA=
|
|
||||||
SMTP_FROM=encrypted:BJmIwi0QgCDhR6cxkHP5EHsWZznB8ixNaHYxFfWMoVGTgHeTwmrXRDPsXBPXBXUMJkpr6yRAOpeADQjJ4TyqhQVw7R1ghk8nzuESH3/oVxPtpBFYaNkahDHMna+x4ZjsRQFwaisFcLm3RPhPQ8WRfR4VtxUjAnSRyfiXNVflsg==
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
name: Validate and deploy production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: shd-build-host-maintenance
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Validate application
|
||||||
|
run: npm run check && npm test
|
||||||
|
|
||||||
|
- name: Build Node application
|
||||||
|
run: npm run build:app
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: validate
|
||||||
|
runs-on: stage-deploy
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Prepare production configuration
|
||||||
|
env:
|
||||||
|
INFISICAL_APP_CLIENT_ID:
|
||||||
|
${{ vars.INFISICAL_PRODUCTION_APP_CLIENT_ID }}
|
||||||
|
INFISICAL_APP_CLIENT_SECRET:
|
||||||
|
${{ secrets.INFISICAL_PRODUCTION_APP_CLIENT_SECRET }}
|
||||||
|
INFISICAL_SCHEDULER_CLIENT_ID:
|
||||||
|
${{ vars.INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID }}
|
||||||
|
INFISICAL_SCHEDULER_CLIENT_SECRET:
|
||||||
|
${{ secrets.INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET }}
|
||||||
|
CONFIGURED_DEPLOY_PATH: ${{ vars.PRODUCTION_DEPLOY_PATH }}
|
||||||
|
CONFIGURED_PRODUCTION_PORT: ${{ vars.PRODUCTION_PORT }}
|
||||||
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
|
BUILD_NUMBER: ${{ gitea.run_number }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
: "${INFISICAL_APP_CLIENT_ID:?INFISICAL_PRODUCTION_APP_CLIENT_ID is required}"
|
||||||
|
: "${INFISICAL_APP_CLIENT_SECRET:?INFISICAL_PRODUCTION_APP_CLIENT_SECRET is required}"
|
||||||
|
: "${INFISICAL_SCHEDULER_CLIENT_ID:?INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID is required}"
|
||||||
|
: "${INFISICAL_SCHEDULER_CLIENT_SECRET:?INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET is required}"
|
||||||
|
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-production}"
|
||||||
|
production_port="${CONFIGURED_PRODUCTION_PORT:-3102}"
|
||||||
|
case "$deployment_path" in
|
||||||
|
/opt/shd-build-*) ;;
|
||||||
|
*) echo "PRODUCTION_DEPLOY_PATH must be beneath /opt/shd-build-." >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$production_port" in
|
||||||
|
''|*[!0-9]*) echo "PRODUCTION_PORT must be an integer." >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
if [ "$production_port" -lt 1 ] || [ "$production_port" -gt 65535 ]; then
|
||||||
|
echo "PRODUCTION_PORT must be from 1 through 65535." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$deployment_path"
|
||||||
|
release_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||||
|
candidate_path="$deployment_path/candidate-$release_id"
|
||||||
|
rm -rf "$candidate_path"
|
||||||
|
mkdir -p "$candidate_path"
|
||||||
|
cp deploy/compose.production.yml deploy/deploy-production.sh deploy/bootstrap-production.sh deploy/sync-production-isac-to-staging.sh "$candidate_path/"
|
||||||
|
write_credential() {
|
||||||
|
target="$1"
|
||||||
|
value="$2"
|
||||||
|
temporary_file=$(mktemp "${target%/*}/.infisical.XXXXXX")
|
||||||
|
printf '%s' "$value" > "$temporary_file"
|
||||||
|
chmod 600 "$temporary_file"
|
||||||
|
mv "$temporary_file" "$target"
|
||||||
|
}
|
||||||
|
write_credential "$candidate_path/.infisical.app.client-id" "$INFISICAL_APP_CLIENT_ID"
|
||||||
|
write_credential "$candidate_path/.infisical.app.client-secret" "$INFISICAL_APP_CLIENT_SECRET"
|
||||||
|
write_credential "$candidate_path/.infisical.scheduler.client-id" "$INFISICAL_SCHEDULER_CLIENT_ID"
|
||||||
|
write_credential "$candidate_path/.infisical.scheduler.client-secret" "$INFISICAL_SCHEDULER_CLIENT_SECRET"
|
||||||
|
temporary_deploy_environment=$(mktemp "$candidate_path/.env.deploy.XXXXXX")
|
||||||
|
printf 'SHD_IMAGE=shd-build-production:%s-%s\nSHD_PRODUCTION_PORT=%s\nSHD_BUILD_NUMBER=%s\nSHD_COMMIT_SHA=%s\n' "$COMMIT_SHA" "$release_id" "$production_port" "$BUILD_NUMBER" "$COMMIT_SHA" > "$temporary_deploy_environment"
|
||||||
|
chmod 600 "$temporary_deploy_environment"
|
||||||
|
mv "$temporary_deploy_environment" "$candidate_path/.env.deploy"
|
||||||
|
chmod 755 "$candidate_path/deploy-production.sh" "$candidate_path/bootstrap-production.sh" "$candidate_path/sync-production-isac-to-staging.sh"
|
||||||
|
|
||||||
|
- name: Deploy production
|
||||||
|
env:
|
||||||
|
CONFIGURED_DEPLOY_PATH: ${{ vars.PRODUCTION_DEPLOY_PATH }}
|
||||||
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-production}"
|
||||||
|
release_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||||
|
candidate_path="$deployment_path/candidate-$release_id"
|
||||||
|
"$candidate_path/deploy-production.sh" "$deployment_path" "$GITHUB_WORKSPACE" "shd-build-production:$COMMIT_SHA-$release_id" "$candidate_path"
|
||||||
|
|
||||||
|
- name: Remove unused application images
|
||||||
|
if: success()
|
||||||
|
run:
|
||||||
|
docker image prune --force --filter
|
||||||
|
"label=org.opencontainers.image.title=shd-build"
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
name: Validate and deploy staging
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: shd-build-host-maintenance
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Validate application
|
||||||
|
run: npm run check && npm test
|
||||||
|
|
||||||
|
- name: Build Node application
|
||||||
|
run: npm run build:app
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: validate
|
||||||
|
runs-on: stage-deploy
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Prepare deployment configuration
|
||||||
|
env:
|
||||||
|
INFISICAL_APP_CLIENT_ID: ${{ vars.INFISICAL_STAGING_APP_CLIENT_ID }}
|
||||||
|
INFISICAL_APP_CLIENT_SECRET:
|
||||||
|
${{ secrets.INFISICAL_STAGING_APP_CLIENT_SECRET }}
|
||||||
|
INFISICAL_SCHEDULER_CLIENT_ID:
|
||||||
|
${{ vars.INFISICAL_STAGING_SCHEDULER_CLIENT_ID }}
|
||||||
|
INFISICAL_SCHEDULER_CLIENT_SECRET:
|
||||||
|
${{ secrets.INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET }}
|
||||||
|
CONFIGURED_DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
||||||
|
CONFIGURED_STAGE_PORT: ${{ vars.STAGE_PORT }}
|
||||||
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
|
BUILD_NUMBER: ${{ gitea.run_number }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
: "${INFISICAL_APP_CLIENT_ID:?INFISICAL_STAGING_APP_CLIENT_ID is required}"
|
||||||
|
: "${INFISICAL_APP_CLIENT_SECRET:?INFISICAL_STAGING_APP_CLIENT_SECRET is required}"
|
||||||
|
: "${INFISICAL_SCHEDULER_CLIENT_ID:?INFISICAL_STAGING_SCHEDULER_CLIENT_ID is required}"
|
||||||
|
: "${INFISICAL_SCHEDULER_CLIENT_SECRET:?INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET is required}"
|
||||||
|
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}"
|
||||||
|
stage_port="${CONFIGURED_STAGE_PORT:-5569}"
|
||||||
|
case "$deployment_path" in
|
||||||
|
/opt/shd-build-*) ;;
|
||||||
|
*) echo "STAGE_DEPLOY_PATH must be beneath /opt/shd-build-." >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$stage_port" in
|
||||||
|
''|*[!0-9]*) echo "STAGE_PORT must be an integer." >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
if [ "$stage_port" -lt 1 ] || [ "$stage_port" -gt 65535 ]; then
|
||||||
|
echo "STAGE_PORT must be from 1 through 65535." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$deployment_path"
|
||||||
|
release_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||||
|
candidate_path="$deployment_path/candidate-$release_id"
|
||||||
|
rm -rf "$candidate_path"
|
||||||
|
mkdir -p "$candidate_path"
|
||||||
|
cp deploy/compose.staging.yml deploy/deploy-staging.sh deploy/bootstrap-staging.sh "$candidate_path/"
|
||||||
|
write_credential() {
|
||||||
|
target="$1"
|
||||||
|
value="$2"
|
||||||
|
temporary_file=$(mktemp "${target%/*}/.infisical.XXXXXX")
|
||||||
|
printf '%s' "$value" > "$temporary_file"
|
||||||
|
chmod 600 "$temporary_file"
|
||||||
|
mv "$temporary_file" "$target"
|
||||||
|
}
|
||||||
|
write_credential "$candidate_path/.infisical.app.client-id" "$INFISICAL_APP_CLIENT_ID"
|
||||||
|
write_credential "$candidate_path/.infisical.app.client-secret" "$INFISICAL_APP_CLIENT_SECRET"
|
||||||
|
write_credential "$candidate_path/.infisical.scheduler.client-id" "$INFISICAL_SCHEDULER_CLIENT_ID"
|
||||||
|
write_credential "$candidate_path/.infisical.scheduler.client-secret" "$INFISICAL_SCHEDULER_CLIENT_SECRET"
|
||||||
|
temporary_deploy_environment=$(mktemp "$candidate_path/.env.deploy.XXXXXX")
|
||||||
|
printf 'SHD_IMAGE=shd-build-stage:%s-%s\nSHD_STAGE_PORT=%s\nSHD_BUILD_NUMBER=%s\nSHD_COMMIT_SHA=%s\n' "$COMMIT_SHA" "$release_id" "$stage_port" "$BUILD_NUMBER" "$COMMIT_SHA" > "$temporary_deploy_environment"
|
||||||
|
chmod 600 "$temporary_deploy_environment"
|
||||||
|
mv "$temporary_deploy_environment" "$candidate_path/.env.deploy"
|
||||||
|
chmod 755 "$candidate_path/deploy-staging.sh" "$candidate_path/bootstrap-staging.sh"
|
||||||
|
|
||||||
|
- name: Deploy staging
|
||||||
|
env:
|
||||||
|
CONFIGURED_DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
||||||
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}"
|
||||||
|
release_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||||
|
candidate_path="$deployment_path/candidate-$release_id"
|
||||||
|
"$candidate_path/deploy-staging.sh" "$deployment_path" "$GITHUB_WORKSPACE" "shd-build-stage:$COMMIT_SHA-$release_id" "$candidate_path"
|
||||||
|
|
||||||
|
- name: Remove unused application images
|
||||||
|
if: success()
|
||||||
|
run:
|
||||||
|
docker image prune --force --filter
|
||||||
|
"label=org.opencontainers.image.title=shd-build"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: Export staging ISAC data
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: shd-build-host-maintenance
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
export:
|
||||||
|
runs-on: stage-deploy
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Export canonical ISAC data and uploaded icons
|
||||||
|
env:
|
||||||
|
CONFIGURED_DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}"
|
||||||
|
compose_file="$deployment_path/compose.staging.yml"
|
||||||
|
deploy_environment="$deployment_path/.env.deploy"
|
||||||
|
compose() {
|
||||||
|
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
|
||||||
|
}
|
||||||
|
snapshot_name="isac-stage-${GITHUB_RUN_ID}.json"
|
||||||
|
volume_snapshot="/app/data/exports/$snapshot_name"
|
||||||
|
remove_volume_snapshot=false
|
||||||
|
cleanup() {
|
||||||
|
exit_status=$?
|
||||||
|
trap - EXIT
|
||||||
|
if [ "$remove_volume_snapshot" = true ]; then
|
||||||
|
compose run --rm --no-deps app rm -f "$volume_snapshot" || true
|
||||||
|
fi
|
||||||
|
if ! compose up -d --no-build app scheduler; then
|
||||||
|
if [ "$exit_status" -eq 0 ]; then exit_status=1; fi
|
||||||
|
fi
|
||||||
|
exit "$exit_status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
compose stop scheduler app
|
||||||
|
app_container="$(compose ps --all --quiet app)"
|
||||||
|
if [ -z "$app_container" ]; then
|
||||||
|
echo "The stopped staging app container could not be found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
remove_volume_snapshot=true
|
||||||
|
compose run --rm --no-deps app node scripts/isac-data-export.mjs "$volume_snapshot"
|
||||||
|
docker cp "$app_container:$volume_snapshot" "$GITHUB_WORKSPACE/isac-stage.json"
|
||||||
|
compose run --rm --no-deps app rm -f "$volume_snapshot"
|
||||||
|
remove_volume_snapshot=false
|
||||||
|
|
||||||
|
- name: Publish ISAC snapshot
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: shd-stage-isac-data
|
||||||
|
path: isac-stage.json
|
||||||
|
retention-days: 14
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: Export production ISAC data
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: shd-build-host-maintenance
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
export:
|
||||||
|
runs-on: stage-deploy
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Export canonical production ISAC data and uploaded icons
|
||||||
|
env:
|
||||||
|
CONFIGURED_DEPLOY_PATH: ${{ vars.PRODUCTION_DEPLOY_PATH }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-production}"
|
||||||
|
compose_file="$deployment_path/compose.production.yml"
|
||||||
|
deploy_environment="$deployment_path/.env.deploy"
|
||||||
|
compose() {
|
||||||
|
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
|
||||||
|
}
|
||||||
|
snapshot_name="isac-production-${GITHUB_RUN_ID}.json"
|
||||||
|
volume_snapshot="/app/data/exports/$snapshot_name"
|
||||||
|
remove_volume_snapshot=false
|
||||||
|
cleanup() {
|
||||||
|
exit_status=$?
|
||||||
|
trap - EXIT
|
||||||
|
if [ "$remove_volume_snapshot" = true ]; then
|
||||||
|
compose run --rm --no-deps app rm -f "$volume_snapshot" || true
|
||||||
|
fi
|
||||||
|
if ! compose up -d --no-build app scheduler; then
|
||||||
|
if [ "$exit_status" -eq 0 ]; then exit_status=1; fi
|
||||||
|
fi
|
||||||
|
exit "$exit_status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
compose stop scheduler app
|
||||||
|
app_container="$(compose ps --all --quiet app)"
|
||||||
|
if [ -z "$app_container" ]; then
|
||||||
|
echo "The stopped production app container could not be found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
remove_volume_snapshot=true
|
||||||
|
compose run --rm --no-deps app node scripts/isac-data-export.mjs "$volume_snapshot"
|
||||||
|
docker cp "$app_container:$volume_snapshot" "$GITHUB_WORKSPACE/isac-production.json"
|
||||||
|
compose run --rm --no-deps app rm -f "$volume_snapshot"
|
||||||
|
remove_volume_snapshot=false
|
||||||
|
|
||||||
|
- name: Publish production ISAC snapshot
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: shd-production-isac-data
|
||||||
|
path: isac-production.json
|
||||||
|
retention-days: 14
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Replace staging ISAC data from production
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: shd-build-host-maintenance
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: stage-deploy
|
||||||
|
steps:
|
||||||
|
- name: Synchronize canonical production data into staging
|
||||||
|
env:
|
||||||
|
CONFIGURED_PRODUCTION_PATH: ${{ vars.PRODUCTION_DEPLOY_PATH }}
|
||||||
|
CONFIGURED_STAGE_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
||||||
|
run: |
|
||||||
|
production_path="${CONFIGURED_PRODUCTION_PATH:-/opt/shd-build-production}"
|
||||||
|
stage_path="${CONFIGURED_STAGE_PATH:-/opt/shd-build-stage}"
|
||||||
|
"$production_path/sync-production-isac-to-staging.sh" \
|
||||||
|
"$production_path" "$stage_path" --confirm-replace-staging-isac-data
|
||||||
+13
-2
@@ -17,10 +17,10 @@ Thumbs.db
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.test
|
!.env.test
|
||||||
!.env.development
|
|
||||||
!.env.production
|
|
||||||
.env.keys
|
.env.keys
|
||||||
.env.keys.*
|
.env.keys.*
|
||||||
|
.infisical.*.client-id
|
||||||
|
.infisical.*.client-secret
|
||||||
|
|
||||||
# Embedded Turso database files
|
# Embedded Turso database files
|
||||||
/data/*.db
|
/data/*.db
|
||||||
@@ -35,3 +35,14 @@ Thumbs.db
|
|||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
_img/
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# Python caches
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
.pi
|
||||||
|
.mesh
|
||||||
|
.worktrees/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
---
|
||||||
|
target: application route system
|
||||||
|
total_score: 28
|
||||||
|
max_score: 40
|
||||||
|
na_heuristics:
|
||||||
|
p0_count: 0
|
||||||
|
p1_count: 2
|
||||||
|
timestamp: 2026-07-26T15-49-23Z
|
||||||
|
slug: src-routes
|
||||||
|
---
|
||||||
|
|
||||||
|
# SHD.build route-system critique
|
||||||
|
|
||||||
|
## Design Health Score
|
||||||
|
|
||||||
|
| # | Heuristic | Score | Key issue |
|
||||||
|
| --------- | ------------------------------- | --------: | ------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| 1 | Visibility of system status | 4 | ISAC exposes environment, access, mode, active navigation, and filter state clearly. |
|
||||||
|
| 2 | Match system / real world | 3 | SHD vocabulary is authentic, but codes such as OSPR and WMOD need plain-language support. |
|
||||||
|
| 3 | User control and freedom | 3 | Navigation and cancellation patterns are strong; empty destinations and filter clearing are less explicit. |
|
||||||
|
| 4 | Consistency and standards | 3 | Shared patterns are cohesive, but live search plus Search, static-looking KPI links, and Edit-only rows diverge. |
|
||||||
|
| 5 | Error prevention | 3 | Guardrails are extensive, but Add/Edit consequence language is not visible before entry. |
|
||||||
|
| 6 | Recognition rather than recall | 2 | Seventeen ISAC destinations, code labels, and visually identical category cards create scanning burden. |
|
||||||
|
| 7 | Flexibility and efficiency | 3 | Search, filtering, page sizes, and grouped navigation help experts; one-page registries expose unnecessary controls. |
|
||||||
|
| 8 | Aesthetic and minimalist design | 2 | The system is disciplined, but cinematic hero scale and equally weighted bordered blocks compete with the task. |
|
||||||
|
| 9 | Error recovery | 3 | The application has designed error and preservation patterns, though recovery was not exercised in the inspected states. |
|
||||||
|
| 10 | Help and documentation | 2 | Microcopy exists, but ISAC terms, permissions, and proposal consequences lack contextual help. |
|
||||||
|
| **Total** | | **28/40** | **Good foundation; focused hierarchy and disclosure work needed.** |
|
||||||
|
|
||||||
|
## Design Specificity Verdict
|
||||||
|
|
||||||
|
The result is strongly authored for SHD.build. Black/graphite command surfaces,
|
||||||
|
orange targeting rails, angular hero cuts, coded modules, explicit system state,
|
||||||
|
and ISAC terminology form a convincing Division intelligence network. The
|
||||||
|
identity becomes less specific where repeated bordered registry cards and KPI
|
||||||
|
blocks flatten into a generic dark administrative grid.
|
||||||
|
|
||||||
|
The deterministic scan found seven source findings across `src/routes`: one
|
||||||
|
warning for the two-pixel semantic notification rail in
|
||||||
|
`src/routes/account/+page.svelte`, and six advisory colors outside the
|
||||||
|
documented palette in the homepage and account route. The rail is largely a
|
||||||
|
detector false positive because it conveys semantic message state, but its
|
||||||
|
colors should still come from named tokens. The homepage build colors are
|
||||||
|
data-like values rather than structural CSS, yet they also bypass the maintained
|
||||||
|
palette.
|
||||||
|
|
||||||
|
Mutable browser injection was unavailable, so no visual overlay is claimed.
|
||||||
|
Fresh Chrome tabs, screenshots, DOM snapshots, and computed styles instead
|
||||||
|
confirmed no horizontal overflow and no visible text below 12px on the public
|
||||||
|
Database or ISAC Attributes pages.
|
||||||
|
|
||||||
|
## Overall Impression
|
||||||
|
|
||||||
|
SHD.build already feels unusually coherent and product-specific. Its biggest
|
||||||
|
opportunity is to let that identity guide attention rather than compete for it:
|
||||||
|
the public Database should reach useful records sooner, and ISAC should reveal
|
||||||
|
its complexity according to the operator's current task and role.
|
||||||
|
|
||||||
|
## What's Working
|
||||||
|
|
||||||
|
1. The visual language is specific, restrained, and thematically credible.
|
||||||
|
Orange remains primarily an action/current-state signal, and textual state
|
||||||
|
accompanies color.
|
||||||
|
2. Shared structures such as KPI filters, registry pagination, database-card
|
||||||
|
anatomy, and the ISAC shell make the large application feel related rather
|
||||||
|
than stitched together.
|
||||||
|
3. Baseline semantics are strong: named navigation, headings, search labels,
|
||||||
|
`aria-current`, disabled-state semantics, and explicit status text are
|
||||||
|
present in the inspected DOM.
|
||||||
|
|
||||||
|
## Priority Issues
|
||||||
|
|
||||||
|
### P1 — ISAC exposes the entire system before the current task
|
||||||
|
|
||||||
|
The Attributes screen simultaneously presents 17 sidebar destinations, four
|
||||||
|
system-state blocks, four KPI filters, search, pagination, row-size controls,
|
||||||
|
six table columns, and repeated Edit actions. Experienced operators can scan it,
|
||||||
|
but contributors and new Oversight users must decode the whole information
|
||||||
|
architecture before acting.
|
||||||
|
|
||||||
|
**Fix:** make sidebar groups collapsible, prioritize modules by role and recent
|
||||||
|
work, hide pagination controls when one page is present, and preserve a compact
|
||||||
|
module switcher/search for experts.
|
||||||
|
|
||||||
|
**Suggested command:** `$impeccable distill`
|
||||||
|
|
||||||
|
### P1 — Mutation consequences are not visible at the decision point
|
||||||
|
|
||||||
|
The interface knows whether an operator publishes directly or creates a review
|
||||||
|
proposal, but the registry's Add/Edit actions do not state that consequence.
|
||||||
|
This is a high-stakes ambiguity for contributors and Oversight.
|
||||||
|
|
||||||
|
**Fix:** place a role-aware consequence line beside the module action and repeat
|
||||||
|
it in the editor header: either “Creates a proposal for Oversight review” or
|
||||||
|
“Publishes directly and creates an audit event.”
|
||||||
|
|
||||||
|
**Suggested command:** `$impeccable clarify`
|
||||||
|
|
||||||
|
### P2 — The Database hero delays the lookup task
|
||||||
|
|
||||||
|
At the inspected 4K viewport the 82px, two-line hero occupies roughly a
|
||||||
|
333px-tall surface, and the category grid starts around 563px down the page.
|
||||||
|
“Contribute data” is visually louder than registry discovery even though lookup
|
||||||
|
is the common task.
|
||||||
|
|
||||||
|
**Fix:** retain the angular tactical hero but reduce its vertical commitment,
|
||||||
|
move useful category identity into the first viewport, and demote contribution
|
||||||
|
to a clear secondary path.
|
||||||
|
|
||||||
|
**Suggested command:** `$impeccable layout`
|
||||||
|
|
||||||
|
### P2 — Empty registries promise the same value as populated registries
|
||||||
|
|
||||||
|
Four of eight Database cards show zero records and “Awaiting contributions,” but
|
||||||
|
look and behave like full destinations. That creates repeated anticipation
|
||||||
|
followed by a low-value endpoint.
|
||||||
|
|
||||||
|
**Fix:** give empty registries an explicit empty-state anatomy with expected
|
||||||
|
content, contribution action, and availability state. Keep them visible, but do
|
||||||
|
not imply the same outcome as populated data.
|
||||||
|
|
||||||
|
**Suggested command:** `$impeccable onboard`
|
||||||
|
|
||||||
|
### P2 — Functional contrast and interaction grammar remain uneven
|
||||||
|
|
||||||
|
Muted database descriptions, inactive navigation, unit sublabels, and pagination
|
||||||
|
text recede heavily. KPI filters resemble static metrics, table rows look
|
||||||
|
selectable while only the small Edit action works, and a Search button remains
|
||||||
|
beside an input that searches as the user types.
|
||||||
|
|
||||||
|
**Fix:** reserve the dimmest tone for nonessential metadata, raise informative
|
||||||
|
copy and available navigation one contrast step, make KPI filter state
|
||||||
|
unmistakably interactive, align desktop row behavior with mobile, and remove or
|
||||||
|
reframe redundant Search controls.
|
||||||
|
|
||||||
|
**Suggested command:** `$impeccable polish`
|
||||||
|
|
||||||
|
## Cognitive Load
|
||||||
|
|
||||||
|
Five checklist failures indicate high load on ISAC: insufficient progressive
|
||||||
|
disclosure, too many simultaneous paths, competing hierarchy, repeated
|
||||||
|
low-information choices, and excessive visible controls for one-page results.
|
||||||
|
Grouping itself is good, but several decision points exceed working-memory
|
||||||
|
limits: six global destinations, eight Database categories, 17 ISAC modules,
|
||||||
|
seven-plus pagination/search controls, and 22 repeated Edit actions.
|
||||||
|
|
||||||
|
## Emotional Journey
|
||||||
|
|
||||||
|
The Database hero creates a confident, premium entry peak. Populated cards
|
||||||
|
promise useful intelligence, but visually equal empty categories produce a
|
||||||
|
disappointment valley. ISAC's “Online / Authorized / Admin” state reassures
|
||||||
|
operators, then the uniform density creates fatigue with little calm focus. The
|
||||||
|
most important missing reassurance is a plain statement of what Add/Edit will do
|
||||||
|
to live data.
|
||||||
|
|
||||||
|
## Persona Red Flags
|
||||||
|
|
||||||
|
**Alex, experienced optimizer:** the Database adds a cinematic layer before
|
||||||
|
facts, empty destinations interrupt rapid lookup, and one-page registries retain
|
||||||
|
unnecessary pagination controls. Recent registries, global data search, and
|
||||||
|
role-aware shortcuts would accelerate expert use.
|
||||||
|
|
||||||
|
**Sam, keyboard and low-vision operator:** semantics and focus foundations are
|
||||||
|
strong, but faint functional text and dozens of repeated Edit actions increase
|
||||||
|
navigation cost. KPI links need clearer control semantics, and role/consequence
|
||||||
|
state should be announced before mutation.
|
||||||
|
|
||||||
|
**Maya, first-time mobile agent:** ISAC, OSPR, WMOD, Oversight, registry, and
|
||||||
|
resolved attributes assume insider fluency. Category icons, plain-language
|
||||||
|
supporting labels, and meaningful empty states would reduce abandonment.
|
||||||
|
|
||||||
|
## Minor Observations
|
||||||
|
|
||||||
|
- Database categories need stronger visual identity; icons would aid recognition
|
||||||
|
and make the incomplete final grid row feel intentional.
|
||||||
|
- The record total “15” can overstate breadth while half the registries are
|
||||||
|
empty; “4 registries online · 15 records” sets expectations better.
|
||||||
|
- Unit values such as `%` and `NO UNIT` are hard to associate with the attribute
|
||||||
|
name at a glance.
|
||||||
|
- Module-count headers add telemetry but do not always improve task choice.
|
||||||
|
- The maintained design sidecar is stale relative to `DESIGN.md`.
|
||||||
|
|
||||||
|
## Questions to Consider
|
||||||
|
|
||||||
|
- Is the Database index primarily a cinematic gateway or a fast lookup tool?
|
||||||
|
- Should empty registries be destinations, contribution prompts, or roadmap
|
||||||
|
teasers?
|
||||||
|
- If ISAC already knows role and mode, why should every operator receive the
|
||||||
|
same full module navigation?
|
||||||
|
- What single sentence must a contributor understand before selecting Add?
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"generatedAt": "2026-07-27T19:09:41.393Z",
|
||||||
|
"title": "Design System: SHD.build",
|
||||||
|
"extensions": {
|
||||||
|
"colorMeta": {
|
||||||
|
"operations-black": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Operations Black",
|
||||||
|
"canonical": "#0a0c0d",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#050606",
|
||||||
|
"#0a0c0d",
|
||||||
|
"#171a1b",
|
||||||
|
"#303536",
|
||||||
|
"#555c5e",
|
||||||
|
"#858b8d",
|
||||||
|
"#c1c4c2",
|
||||||
|
"#f0f0eb"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"terminal-black": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Terminal Black",
|
||||||
|
"canonical": "#0d0f10",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#050606",
|
||||||
|
"#0d0f10",
|
||||||
|
"#1b1e1f",
|
||||||
|
"#34393a",
|
||||||
|
"#596062",
|
||||||
|
"#888f91",
|
||||||
|
"#c3c7c6",
|
||||||
|
"#f1f2ee"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"terminal-surface": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Terminal Surface",
|
||||||
|
"canonical": "#111415",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#070808",
|
||||||
|
"#111415",
|
||||||
|
"#202425",
|
||||||
|
"#393e3f",
|
||||||
|
"#5f6668",
|
||||||
|
"#8d9496",
|
||||||
|
"#c6cac9",
|
||||||
|
"#f2f3ef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"terminal-graphite": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Terminal Graphite",
|
||||||
|
"canonical": "#141718",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#080a0a",
|
||||||
|
"#111415",
|
||||||
|
"#202425",
|
||||||
|
"#343a3b",
|
||||||
|
"#565e60",
|
||||||
|
"#858d8f",
|
||||||
|
"#c0c5c3",
|
||||||
|
"#f0f1ed"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"active-graphite": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Active Graphite",
|
||||||
|
"canonical": "#15191a",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#080a0a",
|
||||||
|
"#15191a",
|
||||||
|
"#252a2b",
|
||||||
|
"#3e4445",
|
||||||
|
"#626a6c",
|
||||||
|
"#909799",
|
||||||
|
"#c8cccb",
|
||||||
|
"#f2f3f0"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"modeled-data-surface": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Modeled Data Surface",
|
||||||
|
"canonical": "#0d1213",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#050708",
|
||||||
|
"#0d1213",
|
||||||
|
"#1c2527",
|
||||||
|
"#334043",
|
||||||
|
"#526165",
|
||||||
|
"#7d8b8e",
|
||||||
|
"#bac2c2",
|
||||||
|
"#eff1ef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"structural-line": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Structural Line",
|
||||||
|
"canonical": "#303536",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#0d0f10",
|
||||||
|
"#1b1f20",
|
||||||
|
"#303536",
|
||||||
|
"#4b5152",
|
||||||
|
"#6a7274",
|
||||||
|
"#92999b",
|
||||||
|
"#c3c8c7",
|
||||||
|
"#f0f1ee"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"structural-line-soft": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Structural Line Soft",
|
||||||
|
"canonical": "#272c2d",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#0b0d0d",
|
||||||
|
"#181b1c",
|
||||||
|
"#272c2d",
|
||||||
|
"#43494a",
|
||||||
|
"#656d6f",
|
||||||
|
"#8e9597",
|
||||||
|
"#c2c6c5",
|
||||||
|
"#eff0ed"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"structural-line-strong": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Structural Line Strong",
|
||||||
|
"canonical": "#424849",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#111415",
|
||||||
|
"#252a2b",
|
||||||
|
"#424849",
|
||||||
|
"#5d6567",
|
||||||
|
"#7c8587",
|
||||||
|
"#a0a7a8",
|
||||||
|
"#c9cdcc",
|
||||||
|
"#f1f2ef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"modeled-data-line": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Modeled Data Line",
|
||||||
|
"canonical": "#3b4648",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#0e1314",
|
||||||
|
"#20292b",
|
||||||
|
"#3b4648",
|
||||||
|
"#566366",
|
||||||
|
"#758184",
|
||||||
|
"#9aa3a5",
|
||||||
|
"#c6cbca",
|
||||||
|
"#f0f2ef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"field-manual-white": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Field Manual White",
|
||||||
|
"canonical": "#ecebe5",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#252522",
|
||||||
|
"#44433f",
|
||||||
|
"#66655f",
|
||||||
|
"#898780",
|
||||||
|
"#aaa8a1",
|
||||||
|
"#cac8c1",
|
||||||
|
"#ecebe5",
|
||||||
|
"#faf9f5"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"telemetry-muted": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Telemetry Muted",
|
||||||
|
"canonical": "#858b8d",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#151718",
|
||||||
|
"#292d2e",
|
||||||
|
"#414647",
|
||||||
|
"#5e6466",
|
||||||
|
"#858b8d",
|
||||||
|
"#a6abad",
|
||||||
|
"#cbd0cf",
|
||||||
|
"#f1f2ef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"telemetry-dim": {
|
||||||
|
"role": "neutral",
|
||||||
|
"displayName": "Telemetry Dim",
|
||||||
|
"canonical": "#5d6567",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#111314",
|
||||||
|
"#222627",
|
||||||
|
"#373c3d",
|
||||||
|
"#4b5254",
|
||||||
|
"#5d6567",
|
||||||
|
"#858d8f",
|
||||||
|
"#bcc1c0",
|
||||||
|
"#eef0ed"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"signal-orange": {
|
||||||
|
"role": "primary",
|
||||||
|
"displayName": "Signal Orange",
|
||||||
|
"canonical": "#f6a821",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#2b1a05",
|
||||||
|
"#4c2f08",
|
||||||
|
"#754a0d",
|
||||||
|
"#a66c14",
|
||||||
|
"#d7901c",
|
||||||
|
"#f6a821",
|
||||||
|
"#ffd278",
|
||||||
|
"#fff1cf"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"signal-orange-hover": {
|
||||||
|
"role": "primary",
|
||||||
|
"displayName": "Signal Orange Hover",
|
||||||
|
"canonical": "#ffc45d",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#2d1f08",
|
||||||
|
"#51380d",
|
||||||
|
"#785515",
|
||||||
|
"#a97824",
|
||||||
|
"#d99d39",
|
||||||
|
"#ffc45d",
|
||||||
|
"#ffdda0",
|
||||||
|
"#fff3d9"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"system-success": {
|
||||||
|
"role": "secondary",
|
||||||
|
"displayName": "System Success",
|
||||||
|
"canonical": "#75c08c",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#102a19",
|
||||||
|
"#194328",
|
||||||
|
"#275f39",
|
||||||
|
"#3f7d50",
|
||||||
|
"#5c9e6d",
|
||||||
|
"#75c08c",
|
||||||
|
"#a8dcba",
|
||||||
|
"#e2f4e8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"system-danger": {
|
||||||
|
"role": "secondary",
|
||||||
|
"displayName": "System Danger",
|
||||||
|
"canonical": "#df8069",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#32130e",
|
||||||
|
"#542219",
|
||||||
|
"#793428",
|
||||||
|
"#a54a39",
|
||||||
|
"#c9634f",
|
||||||
|
"#df8069",
|
||||||
|
"#edae9f",
|
||||||
|
"#f8dfd9"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"system-info": {
|
||||||
|
"role": "secondary",
|
||||||
|
"displayName": "System Information",
|
||||||
|
"canonical": "#70b9d6",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#0d2630",
|
||||||
|
"#163f50",
|
||||||
|
"#245b70",
|
||||||
|
"#387991",
|
||||||
|
"#5599b3",
|
||||||
|
"#70b9d6",
|
||||||
|
"#a6d5e7",
|
||||||
|
"#e1f1f7"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"quality-standard": {
|
||||||
|
"role": "tertiary",
|
||||||
|
"displayName": "Standard Quality",
|
||||||
|
"canonical": "#69b66f",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#0e2912",
|
||||||
|
"#17431d",
|
||||||
|
"#255e2b",
|
||||||
|
"#397b3e",
|
||||||
|
"#519a56",
|
||||||
|
"#69b66f",
|
||||||
|
"#9dd4a1",
|
||||||
|
"#dff0e0"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"quality-specialized": {
|
||||||
|
"role": "tertiary",
|
||||||
|
"displayName": "Specialized Quality",
|
||||||
|
"canonical": "#4f9ed8",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#0c2435",
|
||||||
|
"#123c59",
|
||||||
|
"#1d567d",
|
||||||
|
"#2d72a3",
|
||||||
|
"#408dc4",
|
||||||
|
"#4f9ed8",
|
||||||
|
"#8dc5ea",
|
||||||
|
"#d9edf8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"quality-superior": {
|
||||||
|
"role": "tertiary",
|
||||||
|
"displayName": "Superior Quality",
|
||||||
|
"canonical": "#9b68d4",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#21132f",
|
||||||
|
"#38204f",
|
||||||
|
"#522f70",
|
||||||
|
"#704594",
|
||||||
|
"#8959b7",
|
||||||
|
"#9b68d4",
|
||||||
|
"#c3a2e5",
|
||||||
|
"#eee1f7"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"quality-high-end": {
|
||||||
|
"role": "tertiary",
|
||||||
|
"displayName": "High-end Quality",
|
||||||
|
"canonical": "#e4bd3f",
|
||||||
|
"tonalRamp": [
|
||||||
|
"#2d2408",
|
||||||
|
"#4c3c0d",
|
||||||
|
"#6f5815",
|
||||||
|
"#95771f",
|
||||||
|
"#bd992d",
|
||||||
|
"#e4bd3f",
|
||||||
|
"#f0d982",
|
||||||
|
"#fbf2ce"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typographyMeta": {
|
||||||
|
"display": {
|
||||||
|
"displayName": "Operational Display",
|
||||||
|
"purpose": "Barlow for short public hero statements and major exception titles."
|
||||||
|
},
|
||||||
|
"headline": {
|
||||||
|
"displayName": "Operational Headline",
|
||||||
|
"purpose": "Barlow for routed-page identity and strong editorial section titles."
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"displayName": "Registry Title",
|
||||||
|
"purpose": "Barlow for module, panel, card, and record names."
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"displayName": "System Body",
|
||||||
|
"purpose": "Barlow for functional copy and general interface content."
|
||||||
|
},
|
||||||
|
"editorial": {
|
||||||
|
"displayName": "Field Editorial",
|
||||||
|
"purpose": "Source Serif 4 for human-readable explanations, introductions, and longer descriptions."
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"displayName": "Telemetry Label",
|
||||||
|
"purpose": "Compact uppercase controls, statuses, and metadata."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shadows": [
|
||||||
|
{
|
||||||
|
"name": "detached-menu",
|
||||||
|
"value": "0 18px 46px #000a",
|
||||||
|
"purpose": "Account and compact floating menus."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "operational-dialog",
|
||||||
|
"value": "0 18px 60px #000",
|
||||||
|
"purpose": "Focused quick views and review dialogs."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transient-feedback",
|
||||||
|
"value": "0 12px 30px #000b",
|
||||||
|
"purpose": "Toast notifications."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fault-console",
|
||||||
|
"value": "0 24px 70px #0008",
|
||||||
|
"purpose": "Routed exception surfaces."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"motion": [
|
||||||
|
{
|
||||||
|
"name": "control-state",
|
||||||
|
"value": "transform .12s ease, filter .18s ease, opacity .18s ease, box-shadow .18s ease",
|
||||||
|
"purpose": "Shared tactile interaction feedback."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "toast-enter",
|
||||||
|
"value": "180ms ease-out",
|
||||||
|
"purpose": "Short lateral arrival for transient feedback; removed under reduced motion."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"breakpoints": [
|
||||||
|
{ "name": "mobile-compact", "value": "560px" },
|
||||||
|
{ "name": "mobile-operational", "value": "700px" },
|
||||||
|
{ "name": "navigation-collapse", "value": "900px" },
|
||||||
|
{ "name": "large-display", "value": "2200px" },
|
||||||
|
{ "name": "ultra-display", "value": "2800px" },
|
||||||
|
{ "name": "four-k-display", "value": "3400px" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"name": "Primary Action",
|
||||||
|
"kind": "button",
|
||||||
|
"refersTo": "button-primary",
|
||||||
|
"description": "The decisive action within a task group.",
|
||||||
|
"html": "<button class=\"ds-btn-primary\">SAVE CHANGES <span aria-hidden=\"true\">→</span></button>",
|
||||||
|
"css": ".ds-btn-primary{min-height:40px;border:0;border-radius:0;padding:12px 22px;background:var(--shd-orange,#f6a821);color:#111;font:900 12px/1 Barlow,Arial,sans-serif;letter-spacing:1.6px;text-transform:uppercase;cursor:pointer;transition:transform .12s ease,filter .18s ease,background .18s ease}.ds-btn-primary:hover{background:var(--shd-orange-hover,#ffc45d)}.ds-btn-primary:focus-visible{outline:1px solid var(--shd-focus-ring,#f6a821);outline-offset:2px}.ds-btn-primary:active{transform:translateY(2px) scale(.985);filter:brightness(.9)}.ds-btn-primary:disabled{cursor:wait;opacity:.58}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Secondary Action",
|
||||||
|
"kind": "button",
|
||||||
|
"refersTo": "button-secondary",
|
||||||
|
"description": "A supporting or cancel action with equal geometric discipline.",
|
||||||
|
"html": "<button class=\"ds-btn-secondary\">CANCEL</button>",
|
||||||
|
"css": ".ds-btn-secondary{min-height:40px;border:1px solid var(--shd-border,#303536);border-radius:0;padding:12px 22px;background:transparent;color:var(--shd-text,#ecebe5);font:900 12px/1 Barlow,Arial,sans-serif;letter-spacing:1.6px;text-transform:uppercase;cursor:pointer;transition:transform .12s ease,filter .18s ease,border-color .18s ease}.ds-btn-secondary:hover{border-color:var(--shd-orange,#f6a821);color:var(--shd-orange-hover,#ffc45d)}.ds-btn-secondary:focus-visible{outline:1px solid var(--shd-focus-ring,#f6a821);outline-offset:2px}.ds-btn-secondary:active{transform:translateY(2px) scale(.985);filter:brightness(.9)}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Operational Field",
|
||||||
|
"kind": "input",
|
||||||
|
"refersTo": "input",
|
||||||
|
"description": "A recessed data-entry field with explicit label and precise focus.",
|
||||||
|
"html": "<label class=\"ds-field\"><span>ATTRIBUTE NAME</span><input value=\"Critical Hit Damage\" /></label>",
|
||||||
|
"css": ".ds-field{display:grid;gap:6px;color:var(--shd-text-muted,#858b8d);font:900 12px/1 Barlow,Arial,sans-serif;letter-spacing:1.2px;text-transform:uppercase}.ds-field input{width:100%;min-height:40px;border:1px solid #363b3c;border-radius:0;padding:11px;background:var(--shd-bg-field,#0d0f10);color:var(--shd-text,#ecebe5);font:400 16px/1.3 Barlow,Arial,sans-serif}.ds-field input:focus{border-color:var(--shd-orange,#f6a821);outline:none}.ds-field input:focus-visible{outline:1px solid var(--shd-orange,#f6a821);outline-offset:1px}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Segmented Choice",
|
||||||
|
"kind": "chip",
|
||||||
|
"refersTo": "status-chip",
|
||||||
|
"description": "A bounded radio-style choice rendered as one connected instrument.",
|
||||||
|
"html": "<div class=\"ds-segments\" role=\"radiogroup\" aria-label=\"Tier\"><button role=\"radio\" aria-checked=\"false\">T0</button><button role=\"radio\" aria-checked=\"true\">T1</button><button role=\"radio\" aria-checked=\"false\">T2</button></div>",
|
||||||
|
"css": ".ds-segments{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--shd-border,#303536);background:var(--shd-bg,#0a0c0d)}.ds-segments button{min-height:44px;border:0;border-left:1px solid var(--shd-border,#303536);border-radius:0;background:var(--shd-surface,#111415);color:var(--shd-text-muted,#858b8d);font:900 12px/1 Barlow,Arial,sans-serif;letter-spacing:1px;cursor:pointer}.ds-segments button:first-child{border-left:0}.ds-segments button:hover,.ds-segments button:focus-visible{background:var(--shd-surface-active,#15191a);color:var(--shd-text,#ecebe5)}.ds-segments button:focus-visible{outline:1px solid var(--shd-orange,#f6a821);outline-offset:-2px}.ds-segments button[aria-checked=true]{background:#2b2111;color:var(--shd-orange,#f6a821);box-shadow:inset 0 -2px var(--shd-orange,#f6a821)}.ds-segments button:active{filter:brightness(.82)}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Database Record Card",
|
||||||
|
"kind": "card",
|
||||||
|
"refersTo": "panel",
|
||||||
|
"description": "A public data card built from identity, metadata, and compact fact regions.",
|
||||||
|
"html": "<article class=\"ds-record\"><header><div class=\"ds-record-icon\" aria-hidden=\"true\">SHD</div><div><small>WEAPON // SMG</small><h3>FIRST WAVE VECTOR</h3></div><b>T2</b></header><dl><div><dt>DAMAGE</dt><dd>12,480–15,920</dd></div><div><dt>RPM</dt><dd>1,200</dd></div></dl><p>Level 50 operational values calculated from authoritative game data.</p></article>",
|
||||||
|
"css": ".ds-record{border:1px solid var(--shd-border,#303536);border-top:2px solid #404748;border-radius:0;background:var(--shd-surface,#111415);color:var(--shd-text,#ecebe5);font-family:Barlow,Arial,sans-serif}.ds-record header{min-height:78px;display:grid;grid-template-columns:58px minmax(0,1fr) auto;align-items:center;gap:12px;padding:11px 13px;border-bottom:1px solid var(--shd-border,#303536)}.ds-record-icon{width:58px;height:58px;display:grid;place-items:center;border:1px solid #343a3b;background:#0c0f10;color:var(--shd-orange,#f6a821);font-size:12px;font-weight:900}.ds-record small,.ds-record dt{color:#697174;font-size:12px;font-weight:900;letter-spacing:.8px}.ds-record h3{margin:5px 0 0;font-size:16px;line-height:1.1}.ds-record header>b{padding:4px 6px;border:1px solid #765426;background:#21180d;color:var(--shd-orange,#f6a821);font-size:12px}.ds-record dl{display:grid;grid-template-columns:1fr 1fr;margin:10px 12px 0;border:1px solid var(--shd-border,#303536)}.ds-record dl>div{padding:9px;border-left:1px solid var(--shd-border,#303536)}.ds-record dl>div:first-child{border-left:0}.ds-record dd{margin:5px 0 0;font-size:14px;font-weight:900}.ds-record>p{margin:9px 12px 12px;padding:9px;border-left:1px solid var(--shd-orange,#f6a821);background:#0d1011;color:#a2a8aa;font:14px/1.55 Source Serif 4,Georgia,serif}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ISAC Module Navigation",
|
||||||
|
"kind": "nav",
|
||||||
|
"description": "A dense indexed registry rail with one explicit active module.",
|
||||||
|
"html": "<nav class=\"ds-isac-nav\" aria-label=\"ISAC modules\"><a href=\"#\"><small>01</small><span>ATTRIBUTES</span><b>›</b></a><a class=\"active\" href=\"#\" aria-current=\"page\"><small>02</small><span>TALENTS</span><b>›</b></a><a href=\"#\"><small>03</small><span>GEAR</span><b>›</b></a></nav>",
|
||||||
|
"css": ".ds-isac-nav{display:flex;flex-direction:column;border:1px solid #2c3132;background:#0f1213;font-family:Barlow,Arial,sans-serif}.ds-isac-nav a{min-height:49px;display:grid;grid-template-columns:33px 1fr auto;align-items:center;border-bottom:1px solid #272c2d;color:#858c8e;text-decoration:none}.ds-isac-nav a small{height:100%;display:grid;place-items:center;background:#141819;color:#858b8d;font-size:12px;letter-spacing:.6px}.ds-isac-nav a span{padding-left:11px;font-size:12px;font-weight:700}.ds-isac-nav a b{padding-right:12px;color:var(--shd-orange,#f6a821);font-size:17px}.ds-isac-nav a:hover,.ds-isac-nav a:focus-visible,.ds-isac-nav a.active{background:#151819;color:#fff}.ds-isac-nav a:focus-visible{outline:1px solid var(--shd-orange,#f6a821);outline-offset:-2px}.ds-isac-nav a.active{box-shadow:inset 2px 0 var(--shd-orange,#f6a821)}.ds-isac-nav a.active small{background:#2b2111;color:var(--shd-orange,#f6a821)}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "System Toast",
|
||||||
|
"kind": "custom",
|
||||||
|
"description": "Detached semantic feedback with a concise operational title and dismissal.",
|
||||||
|
"html": "<aside class=\"ds-toast\" role=\"status\"><i></i><div><span>ISAC // COMPLETE</span><strong>ATTRIBUTE SAVED</strong><p>The registry has been updated.</p></div><button aria-label=\"Dismiss\">×</button></aside>",
|
||||||
|
"css": ".ds-toast{--ds-tone:var(--shd-success,#75c08c);min-height:72px;display:grid;grid-template-columns:4px minmax(0,1fr) 34px;overflow:hidden;border:1px solid #414849;border-top:2px solid var(--ds-tone);border-radius:0;background:linear-gradient(105deg,#171b1cee,#0c0f10f7);box-shadow:0 12px 30px #000b;color:var(--shd-text,#ecebe5);font-family:Barlow,Arial,sans-serif}.ds-toast>i{background:var(--ds-tone)}.ds-toast>div{display:grid;align-content:center;gap:4px;padding:11px 10px}.ds-toast span{color:var(--ds-tone);font-size:12px;font-weight:900;letter-spacing:1px}.ds-toast strong{font-size:13px;line-height:1.3}.ds-toast p{margin:0;color:#91999b;font-size:12px;line-height:1.45}.ds-toast button{width:34px;height:34px;margin:4px 3px 0 0;border:0;background:transparent;color:#70787a;font-size:17px;cursor:pointer}.ds-toast button:hover,.ds-toast button:focus-visible{background:#ffffff09;color:var(--ds-tone)}.ds-toast button:focus-visible{outline:1px solid var(--ds-tone);outline-offset:-2px}.ds-toast button:active{transform:translateY(1px);filter:brightness(.8)}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Modeled Authoring Assist",
|
||||||
|
"kind": "custom",
|
||||||
|
"refersTo": "assistant-panel",
|
||||||
|
"description": "A permission-aware draft workspace for mechanic and calculation authoring.",
|
||||||
|
"html": "<section class=\"ds-assist\"><header><div><span>ISAC AUTHORING ASSIST</span><strong>AI MECHANIC DRAFTING</strong></div><b>AUTHORIZED</b><button type=\"button\" aria-expanded=\"false\">OPEN ASSISTANT</button></header><p>Convert in-game wording into a reviewable draft. Manual authoring remains available.</p></section>",
|
||||||
|
"css": ".ds-assist{border:1px solid var(--shd-model-border,#3b4648);border-left:2px solid var(--shd-info,#70b9d6);border-radius:0;background:var(--shd-model-surface,#0d1213);color:var(--shd-text,#ecebe5);font-family:Barlow,Arial,sans-serif}.ds-assist header{min-height:46px;display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:8px;padding:7px 9px;border-bottom:1px solid var(--shd-border,#303536);background:var(--shd-surface-raised,#141718)}.ds-assist header div{display:grid;gap:2px}.ds-assist span,.ds-assist b{color:var(--shd-info,#70b9d6);font-size:12px;font-weight:900;letter-spacing:.9px}.ds-assist b{padding:5px 7px;border:1px solid var(--shd-model-border,#3b4648)}.ds-assist button{min-height:40px;padding:0 11px;border:1px solid var(--shd-info,#70b9d6);border-radius:0;background:transparent;color:var(--shd-info,#70b9d6);font-size:12px;font-weight:900;letter-spacing:.7px;cursor:pointer}.ds-assist button:hover{background:#152225}.ds-assist button:focus-visible{outline:1px solid var(--shd-orange,#f6a821);outline-offset:2px}.ds-assist button:active{transform:translateY(1px);filter:brightness(.84)}.ds-assist p{margin:0;padding:10px 11px;color:var(--shd-text-muted,#858b8d);font-size:14px;line-height:1.45}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"narrative": {
|
||||||
|
"northStar": "The Strategic Operations Grid",
|
||||||
|
"overview": "SHD.build serves active agents through a dependable intelligence network: precise, restrained, tactical, and built around trustworthy data. Alignment, density, operational labels, and visible state carry the interface. Public surfaces use space and larger editorial hierarchy. ISAC compresses the same identity into an efficient control surface.\n\nFine structural lines, layered black and graphite surfaces, cropped game iconography, clipped silhouettes, and tactile state changes give the interface its technical character. Theme language supports comprehension and avoids military cosplay.",
|
||||||
|
"keyCharacteristics": [
|
||||||
|
"Dark, high-contrast operational canvas with restrained tonal layering.",
|
||||||
|
"Square engineered geometry, except circular agent avatars and the SHD mark.",
|
||||||
|
"Dense uppercase telemetry balanced by readable editorial descriptions.",
|
||||||
|
"Signal Orange used sparingly for action, focus, selection, and important data.",
|
||||||
|
"Explicit textual states paired with icons, color, and motion.",
|
||||||
|
"Responsive density from mobile touch layouts through scaled 4K workspaces."
|
||||||
|
],
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"name": "The Orange Discipline Rule",
|
||||||
|
"body": "Use Signal Orange for action and emphasis. Keep it out of large decorative areas and away from semantic state colors.",
|
||||||
|
"section": "colors"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "The Redundant Meaning Rule",
|
||||||
|
"body": "Pair color with text, position, shape, or an icon. A status or quality cannot depend on hue alone.",
|
||||||
|
"section": "colors"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "The Two-Voice Rule",
|
||||||
|
"body": "Sans-serif operates the system; serif explains it. Do not use monospace to make an interface look technical.",
|
||||||
|
"section": "typography"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "The Telemetry Restraint Rule",
|
||||||
|
"body": "Uppercase tracked labels are short navigational or state-bearing signals, not paragraph typography.",
|
||||||
|
"section": "typography"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "The Connected Grid Rule",
|
||||||
|
"body": "Use borders, shared edges, and aligned tracks to show relationships. Do not wrap every subsection in another card.",
|
||||||
|
"section": "layout"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "The Operational Density Rule",
|
||||||
|
"body": "Public discovery may breathe; ISAC and data workbenches prioritize scanability and efficient repeated actions.",
|
||||||
|
"section": "layout"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "The Structural Layering Rule",
|
||||||
|
"body": "Borders and tones establish permanent hierarchy; shadows communicate temporary detachment.",
|
||||||
|
"section": "elevation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "The No Ambient Card Rule",
|
||||||
|
"body": "Ordinary panels and cards remain shadowless at rest.",
|
||||||
|
"section": "elevation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "The Engineered Edge Rule",
|
||||||
|
"body": "Square is the default and rounding must have semantic justification.",
|
||||||
|
"section": "shapes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "The Identity Circle Rule",
|
||||||
|
"body": "Circular crops belong to people, the SHD emblem, and radial information, not arbitrary controls.",
|
||||||
|
"section": "shapes"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dos": [
|
||||||
|
"Do reserve Signal Orange for action, focus, selection, and important operational values.",
|
||||||
|
"Do build hierarchy with connected grids, fine borders, tonal layers, and concise labels.",
|
||||||
|
"Do pair every state color and icon with explicit text.",
|
||||||
|
"Do use reusable segmented choices for bounded sets and searchable assignments for large relational sets.",
|
||||||
|
"Do preserve tactile pressed, focus-visible, pending, disabled, error, success, and reduced-motion behavior.",
|
||||||
|
"Do keep AI-generated data inside an explicit draft-and-review boundary while preserving manual authoring controls.",
|
||||||
|
"Do let public editorial surfaces breathe while keeping ISAC dense and task-oriented."
|
||||||
|
],
|
||||||
|
"donts": [
|
||||||
|
"Don't use camouflage, distressed textures, decorative weapon imagery, or tactical ornament in utility interfaces.",
|
||||||
|
"Don't introduce rounded friendly-SaaS cards, decorative pills, glass panels, gradient text, or ambient card shadows.",
|
||||||
|
"Don't cover large areas in orange or use glow as a substitute for hierarchy.",
|
||||||
|
"Don't turn every section into equal icon-heading-description cards or nest cards inside cards.",
|
||||||
|
"Don't use monospace as a technical costume or tracked uppercase type for long-form copy.",
|
||||||
|
"Don't let operational terminology obscure the action, consequence, recovery, or state it describes."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"files": ["src/app.html"],
|
||||||
|
"insertBefore": "</body>",
|
||||||
|
"commentSyntax": "html",
|
||||||
|
"cspChecked": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"workspaceId": "2ee94b27-e599-4194-9797-3372d42e53f6",
|
||||||
|
"defaultEnvironment": "dev",
|
||||||
|
"defaultSecretPath": "/app",
|
||||||
|
"domain": "https://secrets.jb.codes"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Ignore artifacts:
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
tmp
|
||||||
|
_img
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"arrowParens": "always",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"htmlWhitespaceSensitivity": "css",
|
||||||
|
"insertPragma": false,
|
||||||
|
"jsxBracketSameLine": false,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"printWidth": 80,
|
||||||
|
"proseWrap": "always",
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"requirePragma": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"useTabs": false
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"recommendations": ["svelte.svelte-vscode"]
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,198 @@
|
|||||||
# Repository Instructions
|
# Repository Instructions
|
||||||
|
|
||||||
|
## Parallel task execution
|
||||||
|
|
||||||
|
For substantial changes, use subagents as a standard practice when the work can
|
||||||
|
be divided into concrete, independent tasks that provide real parallel progress.
|
||||||
|
|
||||||
|
1. Delegate bounded mechanical work to faster, lower-reasoning agents (focused
|
||||||
|
code discovery, repetitive call-site updates, documentation, isolated test
|
||||||
|
coverage).
|
||||||
|
2. Keep architecture, security-sensitive behavior, database migrations, shared
|
||||||
|
data-model decisions, and complex debugging with a suitably capable agent.
|
||||||
|
3. Assign explicit file or subsystem ownership before parallel edits. Avoid
|
||||||
|
having multiple agents edit the same files unless their work is deliberately
|
||||||
|
sequenced.
|
||||||
|
4. Do not delegate tightly coupled work merely to increase agent count. Keep it
|
||||||
|
with the primary agent when coordination would cost more than it saves.
|
||||||
|
5. The primary agent remains responsible for integrating results, reviewing
|
||||||
|
diffs, resolving cross-cutting concerns, and running the final required
|
||||||
|
validation.
|
||||||
|
6. Subagents must follow every repository rule that applies to their assigned
|
||||||
|
work, including design-system maintenance, audit logging, and database
|
||||||
|
preservation.
|
||||||
|
|
||||||
|
## Documentation prose
|
||||||
|
|
||||||
|
Use the `stop-slop` skill whenever an agent creates or changes project
|
||||||
|
documentation. Apply its review to the complete affected document, including
|
||||||
|
sections that the change did not introduce. Preserve technical meaning,
|
||||||
|
commands, paths, formulas, links, policy strength, and product terminology.
|
||||||
|
Remove filler, vague claims, formulaic contrasts, manufactured emphasis, false
|
||||||
|
agency, and repetition. Prefer active voice and specific subjects. Do not weaken
|
||||||
|
terms such as `must`, `never`, or `always` when they express a real requirement.
|
||||||
|
|
||||||
|
## Dependency source research
|
||||||
|
|
||||||
|
Use `npx opensrc` before inspecting an npm dependency's implementation. It
|
||||||
|
resolves the package version from this workspace's lockfile, shallow-clones the
|
||||||
|
matching source revision, and caches it outside the repository at `~/.opensrc/`.
|
||||||
|
|
||||||
|
- Do not guess at dependency behavior or implementation details. Identify the
|
||||||
|
needed function, type, option, or control flow in the cached source and use
|
||||||
|
that evidence when designing a change.
|
||||||
|
- Search the smallest relevant source area first. Use the package entry point,
|
||||||
|
exports, types, and targeted code search to locate the behavior instead of
|
||||||
|
reading unrelated files.
|
||||||
|
- Fetch source before a dependency investigation:
|
||||||
|
`npx opensrc fetch --cwd . <package>`.
|
||||||
|
- Print the cached source path for use with repository search and file tools:
|
||||||
|
`npx opensrc path --cwd . <package>`.
|
||||||
|
- Fetch several declared packages in one command:
|
||||||
|
`npx opensrc fetch --cwd . <package-a> <package-b>`.
|
||||||
|
- After adding or upgrading a direct dependency, fetch its source at the locked
|
||||||
|
version. Do not commit `~/.opensrc/` or copy dependency source into this
|
||||||
|
repository.
|
||||||
|
- Use `npx opensrc list` to inspect the cache. Use
|
||||||
|
`npx opensrc remove <package>` or `npx opensrc clean` only when cache removal
|
||||||
|
is intended.
|
||||||
|
|
||||||
|
## Available skills
|
||||||
|
|
||||||
|
The `.agents/skills/` directory contains repo-specific skill files. Key skills:
|
||||||
|
|
||||||
|
- `stop-slop`: apply to documentation prose changes
|
||||||
|
- `frontend-design` / `impeccable`: UI design, refinement, and polish
|
||||||
|
- `import-talent-screenshot` / `import-weapon-screenshot`: extract Division
|
||||||
|
Resurgence game data from screenshots and enter through ISAC
|
||||||
|
- `webapp-testing`: Playwright-based local web app testing
|
||||||
|
|
||||||
|
Use `skill` tool to load one when the task matches.
|
||||||
|
|
||||||
|
## Agent skills
|
||||||
|
|
||||||
|
### Issue tracker
|
||||||
|
|
||||||
|
Track issues in this repository's Gitea Issues using `tea`. See
|
||||||
|
`docs/agents/issue-tracker.md`.
|
||||||
|
|
||||||
|
### Triage labels
|
||||||
|
|
||||||
|
Use the five canonical triage labels without overrides. See
|
||||||
|
`docs/agents/triage-labels.md`.
|
||||||
|
|
||||||
|
### Domain docs
|
||||||
|
|
||||||
|
Use the single-context domain documentation layout. See `docs/agents/domain.md`.
|
||||||
|
|
||||||
|
## Verify commands
|
||||||
|
|
||||||
|
Run in order after source changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check # svelte-kit sync + svelte-check (typecheck)
|
||||||
|
npm run build # secretless Vite production build
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run check` runs `svelte-kit sync` (generates `.svelte-kit/tsconfig.json`)
|
||||||
|
then `svelte-check --tsconfig ./tsconfig.json`. `npm test` runs all vitest
|
||||||
|
tests; there is no separate vitest config (vitest inherits from
|
||||||
|
`vite.config.ts`).
|
||||||
|
|
||||||
|
Test files (`*.test.ts`) live adjacent to the code they test, mostly in
|
||||||
|
`src/lib/`. Most server-unit tests mock `$lib/server/database` via
|
||||||
|
`vi.mock(...)`; pure logic tests do not need that mock. Integration tests that
|
||||||
|
need database state use the embedded Turso engine.
|
||||||
|
|
||||||
|
## Framework and toolchain quirks
|
||||||
|
|
||||||
|
- **SvelteKit 2 / Svelte 5** with forced runes mode (configured in
|
||||||
|
`vite.config.ts` via plugin `compilerOptions.runes`). There is no
|
||||||
|
`svelte.config.js`; SvelteKit config lives in `vite.config.ts`.
|
||||||
|
- **Node adapter** (`@sveltejs/adapter-node`). Production entry point is
|
||||||
|
`node build`. The container runs migrations on startup, then serves.
|
||||||
|
- **Embedded Turso** (`@tursodatabase/database`): local SQLite, no Turso Cloud,
|
||||||
|
no libSQL HTTP, no replicas. The Infisical `dev` environment sets the database
|
||||||
|
path to `./data/shd-build-development.db`.
|
||||||
|
- **Passkey auth** (`@simplewebauthn`) is preferred. `APP_ORIGIN` must match the
|
||||||
|
public origin. `PASSKEY_RP_ID` must be that hostname or a registrable parent
|
||||||
|
domain; hosted environments use `shd.build`.
|
||||||
|
- **Infisical secrets** live in project `2ee94b27-e599-4194-9797-3372d42e53f6`
|
||||||
|
at `https://secrets.jb.codes`. Application values use `/app`;
|
||||||
|
`SCHEDULER_TOKEN` uses `/shared/scheduler`. Local commands use the
|
||||||
|
authenticated Infisical CLI and the `dev` environment. Deployed app and
|
||||||
|
scheduler containers use separate Universal Auth bootstrap credentials mounted
|
||||||
|
through Compose secrets. The root entrypoint exchanges those credentials for
|
||||||
|
one-shot JSON exports, deletes the export files, and starts the workload as
|
||||||
|
the dedicated `shd-runtime` UID 10001 without Infisical credentials or tokens.
|
||||||
|
The self-hosted Free plan cannot scope Viewer identities by environment or
|
||||||
|
path.
|
||||||
|
- **Argon2id** password hashing with server-side sessions and CSRF protection.
|
||||||
|
- **ISAC namespace**: `/isac` is the admin console; `/admin` redirects there
|
||||||
|
via 308.
|
||||||
|
|
||||||
|
## Long-running development processes
|
||||||
|
|
||||||
|
Use the installed `pi-background-tasks` extension's `bg_run` tool for
|
||||||
|
development servers, watchers, and other persistent commands. Do not use
|
||||||
|
foreground shell sessions, `nohup`, `&`, or process-tree/PID discovery when a
|
||||||
|
tracked task exists.
|
||||||
|
|
||||||
|
- Use `isAgent: false` and a concise, descriptive task name.
|
||||||
|
- Retain the returned task ID and output path. Inspect a task with `bg_status`
|
||||||
|
and read its output with `bg_logs`.
|
||||||
|
- Stop a task with `bg_kill <task-id>`. To restart it, start a new `bg_run` task
|
||||||
|
and retain its new task ID and output path; do not search for and kill
|
||||||
|
processes.
|
||||||
|
- Do not leave duplicate tracked tasks for the same development service.
|
||||||
|
|
||||||
|
## Development server lifecycle
|
||||||
|
|
||||||
|
After changing source code, styles, configuration, dependencies, or routes,
|
||||||
|
restart the development server before handing work back.
|
||||||
|
|
||||||
|
1. Start `npm run dev` from the repository root with `bg_run`, using
|
||||||
|
`isAgent: false` and a descriptive task name. Retain its task ID and output
|
||||||
|
path.
|
||||||
|
2. Before starting a replacement, stop the existing tracked development-server
|
||||||
|
task with `bg_kill <task-id>` so only one server runs.
|
||||||
|
3. Confirm the task logs show the server started, then confirm it accepts an
|
||||||
|
HTTP request before completing the task.
|
||||||
|
4. Leave the verified tracked task running so the project owner can review the
|
||||||
|
changes.
|
||||||
|
|
||||||
|
Database maintenance may require the server to remain stopped while the embedded
|
||||||
|
database is locked. Stop its tracked task first, then restart and verify it
|
||||||
|
after maintenance completes.
|
||||||
|
|
||||||
## SHD design-system maintenance
|
## SHD design-system maintenance
|
||||||
|
|
||||||
Treat `docs/DESIGN_SYSTEM.md` and the shared tokens and patterns in `src/app.css`
|
Treat `docs/DESIGN_SYSTEM.md` and the shared tokens in `src/app.css` as
|
||||||
as maintained parts of the product.
|
maintained parts of the product. When a change makes a significant visual or
|
||||||
|
interaction-design decision, update the design system in the same change.
|
||||||
|
|
||||||
Whenever a change makes a significant visual or interaction-design decision,
|
For significant design changes: reuse or extend CSS custom properties from
|
||||||
update the design system in the same change. This includes:
|
`src/app.css`; update `docs/DESIGN_SYSTEM.md`; refactor repeated UI into a
|
||||||
|
reusable component; verify hover, pressed, focus-visible, pending, disabled,
|
||||||
|
error, success, and reduced-motion behavior. Run
|
||||||
|
`npm run check && npm run build` before completing.
|
||||||
|
|
||||||
- introducing or changing colors, typography, spacing, borders, elevation, or motion;
|
Design principles: tactical but restrained, data-first, square and engineered,
|
||||||
- creating a new reusable layout, navigation, table, editor, form, card, status, or feedback pattern;
|
orange reserved for action/emphasis, explicit textual state indicators.
|
||||||
- materially changing dashboard density, hierarchy, responsive behavior, or accessibility behavior;
|
|
||||||
- changing the semantic meaning of an existing visual state;
|
|
||||||
- establishing a pattern that future pages or components should follow.
|
|
||||||
|
|
||||||
For significant design changes:
|
|
||||||
|
|
||||||
1. Reuse or extend the CSS custom properties in `src/app.css`; do not scatter
|
|
||||||
near-duplicate hard-coded values throughout new components.
|
|
||||||
2. Update `docs/DESIGN_SYSTEM.md` with the new or revised rule, token, pattern,
|
|
||||||
usage guidance, and responsive or accessibility considerations.
|
|
||||||
3. Refactor repeated UI into a reusable component when that makes the pattern
|
|
||||||
clearer and reduces divergence.
|
|
||||||
4. Preserve the SHD design principles: tactical but restrained, data-first,
|
|
||||||
square and engineered, orange reserved for action and emphasis, and explicit
|
|
||||||
textual state indicators.
|
|
||||||
5. Verify hover, pressed, focus-visible, pending, disabled, error, success, and
|
|
||||||
reduced-motion behavior where applicable.
|
|
||||||
6. Run `npm run check` and `npm run build` before completing the change.
|
|
||||||
|
|
||||||
Minor alignment fixes, isolated bug fixes, and corrections that do not introduce
|
Minor alignment fixes, isolated bug fixes, and corrections that do not introduce
|
||||||
or revise a reusable design decision do not require a documentation update.
|
a reusable design decision do not require a documentation update.
|
||||||
|
|
||||||
If uncertain whether a visual change is significant, update the design system.
|
|
||||||
|
|
||||||
## Reusable interface patterns
|
## Reusable interface patterns
|
||||||
|
|
||||||
Favor shared, generic components whenever a visual or interaction pattern is
|
Favor shared components from `src/lib/components`. Before adding page-local
|
||||||
used in more than one place or is likely to recur. Before adding page-local
|
markup, check if a shared component covers the need. When introducing a reusable
|
||||||
markup and styles, check `src/lib/components` and extend an appropriate shared
|
pattern, migrate the clearest existing duplicate in the same change.
|
||||||
component when practical. When introducing a reusable pattern, migrate the
|
|
||||||
clearest existing duplicate in the same change so the component has more than
|
|
||||||
one real consumer.
|
|
||||||
|
|
||||||
For small, finite option sets, prefer the shared segmented-choice pattern over
|
For small, finite option sets, prefer the shared segmented-choice pattern over a
|
||||||
a dropdown. Use radio semantics for exactly one choice and checkbox semantics
|
dropdown. Use radio semantics for exactly one choice and checkbox semantics for
|
||||||
for additive independent choices. Reserve dropdowns for long, dynamic, or
|
additive independent choices. Reserve dropdowns for long, dynamic, or
|
||||||
search-oriented option sets. Preserve native form semantics, keyboard access,
|
search-oriented option sets.
|
||||||
focus-visible, selected, disabled, pressed, responsive, and reduced-motion
|
|
||||||
behavior in shared controls.
|
|
||||||
|
|
||||||
## ISAC editor lifecycle
|
## ISAC editor lifecycle
|
||||||
|
|
||||||
@@ -56,148 +200,92 @@ All ISAC data-management editors must follow the same successful-save behavior:
|
|||||||
|
|
||||||
1. Saving an edit to an existing record closes the editor and returns the user
|
1. Saving an edit to an existing record closes the editor and returns the user
|
||||||
to the registry.
|
to the registry.
|
||||||
2. Saving a newly added record keeps the editor open with a fresh blank record
|
2. Saving a newly added record keeps the editor open with a fresh blank record.
|
||||||
so consecutive data entry remains fast.
|
|
||||||
3. Validation errors keep the editor open and preserve the entered values.
|
3. Validation errors keep the editor open and preserve the entered values.
|
||||||
4. Server actions must return the add/edit mode and a unique result token;
|
4. Server actions must return the add/edit mode and a unique result token;
|
||||||
client-side reactive handling must consume each token only once to avoid
|
client-side reactive handling must consume each token only once to avoid
|
||||||
repeated state updates.
|
repeated state updates.
|
||||||
5. When a successful add keeps an icon-enabled editor open, increment the
|
5. When a successful add keeps an icon-enabled editor open, increment the shared
|
||||||
shared upload reset token so the native file input, generated preview,
|
upload reset token so the file input, preview, filename, drag/paste state,
|
||||||
filename, drag/paste state, validation message, and removal state are fresh
|
validation message, and removal state are fresh for the next record.
|
||||||
for the next record.
|
|
||||||
|
|
||||||
Apply this convention whenever an ISAC editor is added or materially changed.
|
Apply this convention whenever an ISAC editor is added or materially changed.
|
||||||
|
|
||||||
## ISAC audit logging
|
## ISAC audit logging
|
||||||
|
|
||||||
Every successful server-side mutation performed through ISAC must create an
|
Every successful server-side ISAC mutation must create an append-only audit
|
||||||
append-only audit event. Audit coverage is a required part of implementing or
|
event. Follow these rules:
|
||||||
changing any ISAC module, action, endpoint, bulk operation, moderation control,
|
|
||||||
identity operation, access-policy change, archive action, or destructive action.
|
|
||||||
|
|
||||||
Follow these rules:
|
1. Use the shared audit utilities in `src/lib/server/audit.ts`; do not write
|
||||||
|
directly to `audit_log`.
|
||||||
1. Use the shared audit utilities in `src/lib/server/audit.ts`; do not invent
|
2. Record the authenticated operator ID, stable category, specific action
|
||||||
module-specific audit formats or write directly to `audit_log` in new code.
|
(namespaced past tense, e.g. `weapon.updated`), target entity ID, timestamp,
|
||||||
2. Record the authenticated operator ID, stable category, specific action,
|
and useful before/after snapshots. Categories include `game_data`,
|
||||||
target entity ID, timestamp, and useful before/after snapshots.
|
`user_access`, `user_moderation`, `identity`, `role_management`.
|
||||||
3. Categories describe durable operational domains such as `game_data`,
|
3. Capture changes to the primary row and its relationships. Include meaningful
|
||||||
`user_access`, `user_moderation`, `identity`, and `role_management`.
|
previous and resulting sets for linked records.
|
||||||
Actions describe the exact event in namespaced past tense, such as
|
4. Create audit records only after validation succeeds. Rejected or failed
|
||||||
`weapon.updated`, `attribute.archived`, or `moderation.blocked`.
|
|
||||||
4. Capture relationship changes as well as the primary row. When an action
|
|
||||||
replaces permission sets, compatible brands, talents, effects, tiers, or
|
|
||||||
other linked records, include the meaningful previous and resulting sets.
|
|
||||||
5. Create audit records only after validation succeeds and the requested
|
|
||||||
mutation has completed. Rejected, unauthorized, or validation-failed
|
|
||||||
requests must not appear as successful mutation events.
|
requests must not appear as successful mutation events.
|
||||||
6. Never include password hashes, session tokens, magic-link tokens, OAuth
|
5. Never include password hashes, session tokens, magic-link tokens, OAuth
|
||||||
state, passkey public-key bytes, SMTP credentials, environment secrets, or
|
state, passkey public-key bytes, SMTP credentials, or environment secrets in
|
||||||
other authentication secrets in audit snapshots. Minimize personal data to
|
audit snapshots.
|
||||||
what is necessary for security, support, and accountability.
|
6. Audit visibility is controlled by `audit.view`. The read-only `Auditor` role
|
||||||
7. Audit visibility is controlled by `audit.view`. The read-only `Auditor` role
|
grants `isac.access` and `audit.view` without granting mutation permissions.
|
||||||
grants `isac.access` and `audit.view`; it must not implicitly grant mutation,
|
7. Audit reversion is a compensating action, never a history rewrite. Require
|
||||||
moderation, identity-management, or user-access permissions.
|
`audit.revert`, confirmation, current-state comparison, relationship
|
||||||
8. New audit categories and actions must remain searchable in the Audit History
|
validation, and a new `audit.reverted` event.
|
||||||
registry. Update its grouped filters when a new durable operational domain is
|
8. Tests for an ISAC mutation must confirm its audit event contains the correct
|
||||||
introduced.
|
actor, category, action, target, and change evidence.
|
||||||
9. Preserve audit history during normal record archival and soft deletion.
|
|
||||||
Never add an ISAC action that edits or deletes audit records without an
|
## Prototype compatibility
|
||||||
explicit retention policy, legal review, and separately audited authority.
|
|
||||||
10. Tests and handoff verification for an ISAC mutation must include confirming
|
Treat application data and client payloads as prototype formats until the
|
||||||
that its audit event contains the correct actor, category, action, target,
|
project owner declares a production-stable format.
|
||||||
and change evidence.
|
|
||||||
11. Audit reversion is a separately authorized compensating action, never a
|
1. Prefer one current validated schema over compatibility readers, repair
|
||||||
history rewrite. Require `audit.revert`, an explicit confirmation
|
branches, dual writes, or fallback parsing for obsolete formats.
|
||||||
interaction, current-state comparison against the original after-snapshot,
|
2. Remove incompatible local drafts, saved builds, calibration snapshots, and
|
||||||
relationship validation, and a new `audit.reverted` event referencing the
|
other derived records through a backed-up migration when the current model
|
||||||
original event. Block stale, already-reverted, unsupported, destructive
|
cannot represent them safely.
|
||||||
identity, and incomplete-snapshot events rather than guessing.
|
3. Preserve authored game data whenever the new model can retain it without
|
||||||
|
guessing.
|
||||||
|
4. Follow the database-preservation rules before removing or reshaping
|
||||||
|
persistent data.
|
||||||
|
5. Add compatibility support only when the project owner requires it for a
|
||||||
|
production-stable format.
|
||||||
|
|
||||||
|
## Talent mechanic conventions
|
||||||
|
|
||||||
|
Before importing a Talent or changing its mechanic program, read
|
||||||
|
`docs/TALENT_MECHANIC_RULES.md`. Treat the confirmed lifecycle, trigger,
|
||||||
|
cooldown, and targeting rules in that document as authoritative game behavior.
|
||||||
|
Do not mark a mechanic `requires_assumptions` for behavior that the document
|
||||||
|
resolves. When the project owner clarifies another Talent mechanic, record the
|
||||||
|
answer in `docs/TALENT_MECHANIC_RULES.md`.
|
||||||
|
|
||||||
## Database preservation
|
## Database preservation
|
||||||
|
|
||||||
Development data is valuable and may be entered by non-technical contributors.
|
|
||||||
Treat embedded database files as persistent user data, not disposable build
|
Treat embedded database files as persistent user data, not disposable build
|
||||||
artifacts.
|
artifacts.
|
||||||
|
|
||||||
Before deleting, replacing, recreating, renaming, or destructively migrating a
|
Before destructive work on the database, stop the dev server, then create a
|
||||||
database—and before any major schema change—create a timestamped JSON backup
|
verified timestamped JSON backup with `npm run db:backup`. Keep backups out of
|
||||||
with `npm run db:backup`.
|
Git (they can contain user records). Prefer additive, repeatable schema
|
||||||
|
migrations over database recreation. Report the backup path when completing
|
||||||
Database work must follow these rules:
|
database-destructive work.
|
||||||
|
|
||||||
1. Confirm which environment and exact database path are in scope.
|
|
||||||
2. Before every database backup or restore, inspect running processes and stop
|
|
||||||
only this project's development-server process tree. The embedded Turso
|
|
||||||
database holds an exclusive file lock while the dev server is running, so do
|
|
||||||
not attempt the backup first and wait for a locking failure.
|
|
||||||
3. Create and verify a JSON backup before destructive work.
|
|
||||||
4. Never overwrite or delete the only available backup.
|
|
||||||
5. Keep backups out of Git; they can contain user records and password hashes.
|
|
||||||
6. Prefer additive, repeatable schema migrations over database recreation.
|
|
||||||
7. After a restore, run the schema migration and verify record counts and
|
|
||||||
critical entities before resuming data entry.
|
|
||||||
8. Report the backup path when completing database-destructive work.
|
|
||||||
|
|
||||||
Use `npm run db:restore -- <backup-file>` only with an empty, migrated target
|
Use `npm run db:restore -- <backup-file>` only with an empty, migrated target
|
||||||
database. The restore script intentionally refuses to merge into a populated
|
database. The command refuses to merge into a populated database.
|
||||||
database.
|
|
||||||
|
|
||||||
## Low-downtime database migration windows
|
## Development database maintenance
|
||||||
|
|
||||||
Friends and contributors may be actively entering game data in ISAC while
|
1. Stop this project's tracked development-server task with `bg_kill <task-id>`.
|
||||||
development continues. Treat dev-server availability and unsaved contributor
|
2. Run and verify `npm run db:backup`. Retain and report the backup path.
|
||||||
work as operational concerns whenever a schema change requires stopping the
|
3. Apply the migration or maintenance operation. Verify schema state, record
|
||||||
embedded database.
|
counts, and critical entities.
|
||||||
|
4. If an operation fails, preserve logs and the verified backup rather than
|
||||||
|
improvising against the database.
|
||||||
|
5. Restart the dev server, then confirm a basic health or page check.
|
||||||
|
|
||||||
Use this sequence for every planned migration window:
|
Production and staging backups and deployment are defined in
|
||||||
|
`docs/DEPLOYMENT.md`.
|
||||||
### Prepare while the server remains online
|
|
||||||
|
|
||||||
1. Implement the application changes and the complete repeatable migration
|
|
||||||
before stopping the server.
|
|
||||||
2. Prefer additive, backward-compatible changes: new tables, new indexes, and
|
|
||||||
nullable or safely defaulted columns. Avoid table copies, column removal,
|
|
||||||
database recreation, or migrations that require the new application code to
|
|
||||||
become active before the schema is ready.
|
|
||||||
3. Make migrations idempotent by inspecting current schema state before each
|
|
||||||
alteration. The migration must be safe to rerun after interruption.
|
|
||||||
4. Batch related schema changes into one window instead of repeatedly stopping
|
|
||||||
the server during the same task.
|
|
||||||
5. Run `npm run check` and `npm run build` before the outage. Do not spend the
|
|
||||||
offline interval editing code, resolving type errors, building assets, or
|
|
||||||
performing ordinary UI review.
|
|
||||||
6. Identify the exact environment, database path, migration steps, verification
|
|
||||||
queries, and rollback/restore backup before stopping anything.
|
|
||||||
|
|
||||||
### Keep the offline window short
|
|
||||||
|
|
||||||
1. Do not start the window while a known contributor is in the middle of an
|
|
||||||
unsaved form or submission. When contributor activity is known, allow them
|
|
||||||
to finish or explicitly coordinate the interruption.
|
|
||||||
2. Inspect processes and stop only this project's dev-server process tree.
|
|
||||||
3. Run `npm run db:backup` immediately, verify that it completed, and retain the
|
|
||||||
reported backup path.
|
|
||||||
4. Run the already-prepared migration immediately after the backup.
|
|
||||||
5. Perform only critical database checks while offline: migration success,
|
|
||||||
expected columns/tables, record counts, and critical entity availability.
|
|
||||||
6. Restart the dev server as soon as the database is safe for application use.
|
|
||||||
Browser testing, extended regression checks, formatting, documentation
|
|
||||||
review, and noncritical diagnostics happen after service is restored.
|
|
||||||
|
|
||||||
### Failure and high-risk handling
|
|
||||||
|
|
||||||
1. If backup, migration, or critical verification fails, do not repeatedly
|
|
||||||
improvise against the live database. Preserve logs and the verified backup,
|
|
||||||
determine whether the existing schema is still safe, and either restart the
|
|
||||||
previous compatible application or follow the documented empty-database
|
|
||||||
restore procedure.
|
|
||||||
2. Separate destructive, data-rewriting, or predictably long migrations from
|
|
||||||
routine additive windows. Report that a maintenance window is required
|
|
||||||
before beginning one.
|
|
||||||
3. Do not claim the service is available until the restarted dev server passes
|
|
||||||
a basic health/page check.
|
|
||||||
4. In the handoff, report the backup path, migration result, server restart,
|
|
||||||
critical verification performed, and approximate offline interval when it
|
|
||||||
can be determined.
|
|
||||||
|
|||||||
+271
@@ -0,0 +1,271 @@
|
|||||||
|
# SHD.build Domain
|
||||||
|
|
||||||
|
SHD.build is an independent community intelligence platform for _The Division
|
||||||
|
Resurgence_. This glossary defines the language used for player planning, shared
|
||||||
|
content, Game Data, governance, equipment, progression, and modeled mechanics.
|
||||||
|
|
||||||
|
## Product and people
|
||||||
|
|
||||||
|
**SHD.build**: The community platform described by this glossary. Always
|
||||||
|
capitalize `SHD` and write the product name as `SHD.build`. _Avoid_: SHD Build,
|
||||||
|
SHD Builder
|
||||||
|
|
||||||
|
**Agent**: Any person using SHD.build, whether anonymous or authenticated.
|
||||||
|
_Avoid_: User, visitor, player when referring to product access
|
||||||
|
|
||||||
|
**Anonymous Agent**: An Agent who is not signed in to SHD.build. _Avoid_:
|
||||||
|
Visitor, guest
|
||||||
|
|
||||||
|
**Registered Agent**: An Agent with an SHD.build account. _Avoid_: Registered
|
||||||
|
user, member
|
||||||
|
|
||||||
|
**Modeling Session**: An interactive agentic work session that prepares one
|
||||||
|
Mechanic Program at a time for a Registered Agent to confirm in chat before the
|
||||||
|
session submits it through an existing ISAC action. _Avoid_: Agent, Modeling
|
||||||
|
Client, bulk job
|
||||||
|
|
||||||
|
**Contributor**: A Registered Agent who has accepted the contributor guidelines
|
||||||
|
and may submit proposed Game Data changes for review. _Avoid_: Editor when the
|
||||||
|
Agent cannot change Canonical Game Data directly
|
||||||
|
|
||||||
|
**Operator**: A Registered Agent performing an authorized task in ISAC.
|
||||||
|
Contributor, reviewer, moderator, and administrator authority remain distinct
|
||||||
|
from this situational term. _Avoid_: Admin as a generic name for anyone using
|
||||||
|
ISAC
|
||||||
|
|
||||||
|
**Oversight**: The human review function for proposed Game Data, moderated
|
||||||
|
Community Content, and identity assets. Use the qualified names Data Oversight,
|
||||||
|
Community Oversight, Guide Oversight, and Avatar Oversight when the review area
|
||||||
|
matters. _Avoid_: Moderation as a name for every review function
|
||||||
|
|
||||||
|
## Planning and shared content
|
||||||
|
|
||||||
|
**Builder**: The planning tool an Agent uses to configure a Loadout and inspect
|
||||||
|
supported calculations. _Avoid_: Build when referring to the tool
|
||||||
|
|
||||||
|
**Loadout**: A complete planned character configuration, including selected
|
||||||
|
Equipment, progression choices, and combat assumptions. A Loadout may exist
|
||||||
|
without becoming a saved Build. _Avoid_: Build when persistence, ownership, or
|
||||||
|
sharing matters
|
||||||
|
|
||||||
|
**Loadout Draft**: An editable Loadout that has not become a saved Build.
|
||||||
|
Anonymous and Registered Agents may both work with drafts. _Avoid_: Build draft
|
||||||
|
|
||||||
|
**Build**: A saved, owned, and shareable planning artifact whose central content
|
||||||
|
is a Loadout. A Build may be Private, Unlisted, or Public and may derive from
|
||||||
|
another Build. _Avoid_: Loadout when persistence, ownership, or sharing matters
|
||||||
|
|
||||||
|
**Guide**: A revisioned long-form community article that may include snapshots
|
||||||
|
of one or more Builds. A Guide is prose-led content rather than a Loadout
|
||||||
|
container. _Avoid_: Build guide when referring to every Guide
|
||||||
|
|
||||||
|
**News Article**: A revisioned editorial or syndicated article published through
|
||||||
|
the News channel. _Avoid_: Guide, post
|
||||||
|
|
||||||
|
**Article Revision**: An immutable edition of a Guide or News Article. Editors
|
||||||
|
select one revision for publication while later revisions may await review.
|
||||||
|
_Avoid_: Version when referring to published article history
|
||||||
|
|
||||||
|
**Community Content**: Agent-authored comments, profile biographies, custom
|
||||||
|
avatars, and Guides governed by the shared moderation lifecycle. _Avoid_:
|
||||||
|
User-generated content, UGC
|
||||||
|
|
||||||
|
**Community Note**: A visible comment that Community Oversight pins to a Public
|
||||||
|
Database discussion. It remains Community Content and follows the same removal
|
||||||
|
rules as other comments. _Avoid_: Official correction, staff note
|
||||||
|
|
||||||
|
**Content Report**: An Agent's request for Oversight to review visible Community
|
||||||
|
Content, with no immediate change to the content's visibility. _Avoid_: Takedown
|
||||||
|
request
|
||||||
|
|
||||||
|
**Public Database**: The Agent-facing reference view of Canonical Game Data.
|
||||||
|
_Avoid_: Database when the term could mean application storage
|
||||||
|
|
||||||
|
## Game Data and governance
|
||||||
|
|
||||||
|
**Game Data**: Community-maintained structured facts about _The Division
|
||||||
|
Resurgence_ used by the Public Database, Builder, Builds, and contribution
|
||||||
|
workflow. _Avoid_: Source data, builder data, reference data
|
||||||
|
|
||||||
|
**Canonical Game Data**: The authoritative, approved body of Game Data. _Avoid_:
|
||||||
|
Live data, production data
|
||||||
|
|
||||||
|
**Canonical Record**: One authoritative record within Canonical Game Data.
|
||||||
|
_Avoid_: Approved proposal
|
||||||
|
|
||||||
|
**Contribution Proposal**: A pending request from a Contributor to create or
|
||||||
|
change Canonical Game Data. Approval still requires the proposed change to
|
||||||
|
satisfy the canonical rules for its record family. _Avoid_: Draft record,
|
||||||
|
pending record
|
||||||
|
|
||||||
|
**ISAC**: The operational console for Game Data, contribution review, community
|
||||||
|
governance, identity and access management, audit history, and system
|
||||||
|
operations. _Avoid_: Admin panel, calculation engine
|
||||||
|
|
||||||
|
**Registry**: An ISAC workspace containing one family of related records and its
|
||||||
|
management controls. _Avoid_: Table, list page
|
||||||
|
|
||||||
|
**Audit Event**: An immutable account of a consequential operation or review
|
||||||
|
decision, including the actor, target, and change evidence. _Avoid_: Audit-log
|
||||||
|
row, activity log
|
||||||
|
|
||||||
|
**Compensating Reversion**: A new operation that restores an earlier state while
|
||||||
|
preserving the original Audit Event and recording the reversal. _Avoid_:
|
||||||
|
Rollback, undo when either implies rewriting history
|
||||||
|
|
||||||
|
## Equipment and progression
|
||||||
|
|
||||||
|
**Equipment**: The items and enhancements selected for a Loadout, including
|
||||||
|
Weapons, Gear, Weapon Mods, Gear Mods, Skill Chips, and an OS Protocol. _Avoid_:
|
||||||
|
Item when the equipment relationship matters
|
||||||
|
|
||||||
|
**Gear**: Equippable protective equipment assigned to a character slot. Gear may
|
||||||
|
provide core values, Attributes, a Brand Set, a Talent, and augmentation
|
||||||
|
capacity. _Avoid_: Armor as a collective name for all Gear
|
||||||
|
|
||||||
|
**Body Armor**: The canonical name for the equippable torso Gear slot. _Avoid_:
|
||||||
|
Vest
|
||||||
|
|
||||||
|
**Weapon**: An equippable firearm with a Weapon Category, damage and handling
|
||||||
|
properties, progression values, and applicable Talents and Mods. _Avoid_: Gun
|
||||||
|
when referring to the modeled record
|
||||||
|
|
||||||
|
**Weapon Category**: The classification that groups Weapons as Shotgun, Rifle,
|
||||||
|
MMR, AR, LMG, or SMG. _Avoid_: Weapon type, weapon family
|
||||||
|
|
||||||
|
**Weapon Mod**: An enhancement fitted to a compatible Weapon slot. Regular and
|
||||||
|
Exotic Weapon Mods follow different selection rules. _Avoid_: Attachment
|
||||||
|
|
||||||
|
**Gear Mod**: An enhancement fitted to Gear according to the Gear's available
|
||||||
|
mod capacity. _Avoid_: Tactical Augment
|
||||||
|
|
||||||
|
**Tactical Augment**: A numbered augmentation position recorded on selected Gear
|
||||||
|
in a Loadout. _Avoid_: Gear Mod
|
||||||
|
|
||||||
|
**Attribute**: A named measurable game property with defined units and
|
||||||
|
calculation meaning. _Avoid_: Stat when referring to the canonical Attribute
|
||||||
|
catalog
|
||||||
|
|
||||||
|
**Core Attribute**: One of the three primary character alignments: Firepower,
|
||||||
|
Toughness, or Engineering. _Avoid_: Main stat, primary stat
|
||||||
|
|
||||||
|
**Brand Set**: A Gear identity whose bonuses depend on how many pieces from that
|
||||||
|
set the Loadout equips. _Avoid_: Gear set
|
||||||
|
|
||||||
|
**Quality**: The item classification Standard, Specialized, Superior, or
|
||||||
|
High-end. Quality governs available or scaled properties for applicable
|
||||||
|
Equipment. _Avoid_: Rarity
|
||||||
|
|
||||||
|
**Exotic**: A special scope used by certain Weapons, Talents, and Weapon Mods.
|
||||||
|
Exotic is not a fifth Quality. _Avoid_: Exotic quality
|
||||||
|
|
||||||
|
**Item Level**: An Equipment progression coordinate used to resolve
|
||||||
|
level-dependent values. _Avoid_: Tier, Quality
|
||||||
|
|
||||||
|
**Equipment Tier**: The T0, T1, or T2 progression band that controls Equipment
|
||||||
|
capabilities and values. _Avoid_: Talent Tier, Item Level
|
||||||
|
|
||||||
|
**Talent Tier**: The Tier I or Tier II value variant of a Talent. _Avoid_:
|
||||||
|
Equipment Tier, T1, T2 when the distinction could be unclear
|
||||||
|
|
||||||
|
**Reference Level**: The Item Level at which an observed value or range applies.
|
||||||
|
_Avoid_: Base level
|
||||||
|
|
||||||
|
**Hidden Base**: A level-independent value inferred from observed Equipment
|
||||||
|
values and the game's progression rules. _Avoid_: Displayed Value, Raw Value
|
||||||
|
|
||||||
|
**Raw Value**: A calculated value before the presentation rules that reproduce
|
||||||
|
the game's displayed number. _Avoid_: Hidden Base, Displayed Value
|
||||||
|
|
||||||
|
**Displayed Value**: The player-visible number produced after applying the
|
||||||
|
game's presentation rules to a Raw Value. _Avoid_: Raw Value
|
||||||
|
|
||||||
|
**OS Protocol**: A single equipped progression item with a Quality, Core
|
||||||
|
Attribute alignment, primary effect, inherited secondary profile, and
|
||||||
|
upgradeable Talent. _Avoid_: Protocol when the operating-system meaning could be
|
||||||
|
inferred
|
||||||
|
|
||||||
|
**Skill**: A selectable combat capability associated with a Specialization.
|
||||||
|
Skills may own Skill Chip Sets and may have explicit compatibility relationships
|
||||||
|
with another Skill. _Avoid_: Ability or Specialization Ability for the
|
||||||
|
selectable game concept
|
||||||
|
|
||||||
|
**Signature Skill**: A Specialization's defining Skill, distinguished from its
|
||||||
|
other selectable Skills. _Avoid_: Signature ability
|
||||||
|
|
||||||
|
**Skill Chip**: An Equipment selection that modifies the Skill occupying its
|
||||||
|
Loadout position. Skill Chips belong to a Skill Chip Set and may provide
|
||||||
|
primary, secondary, and set effects. _Avoid_: Chip when the equipment family is
|
||||||
|
unclear
|
||||||
|
|
||||||
|
**Skill Chip Set**: A reusable Skill Chip definition associated with a Skill,
|
||||||
|
including its set bonus and optional Talent. An explicit compatibility
|
||||||
|
relationship may let another Skill equip the set. _Avoid_: Skill set
|
||||||
|
|
||||||
|
**Specialization**: A character progression identity containing Focuses, Skills,
|
||||||
|
and progression choices. _Avoid_: Class
|
||||||
|
|
||||||
|
**Focus**: A path within a Specialization aligned to one Core Attribute and
|
||||||
|
defining a selection of Skills and progression choices. _Avoid_: Subclass,
|
||||||
|
specialization path
|
||||||
|
|
||||||
|
**Talent**: A named conditional behavior owned by its source, such as a Weapon,
|
||||||
|
Gear item, OS Protocol, or Skill Chip Set. Different Talent sources may use
|
||||||
|
different progression variants. _Avoid_: Perk, passive
|
||||||
|
|
||||||
|
## Modeled mechanics
|
||||||
|
|
||||||
|
**Mechanic Program**: The structured, source-owned description of a Talent or
|
||||||
|
other conditional behavior. It captures the conditions, state changes, and
|
||||||
|
outcomes needed to evaluate that behavior. _Avoid_: Script, Trigger record
|
||||||
|
|
||||||
|
**Event**: An observed occurrence on a combat timeline that a Mechanic Program
|
||||||
|
may inspect. _Avoid_: Trigger when referring to the occurrence itself
|
||||||
|
|
||||||
|
**Trigger Condition**: The criteria that an Event must satisfy before a mechanic
|
||||||
|
can proceed toward Activation. A Trigger Condition is part of a Mechanic
|
||||||
|
Program, not a standalone Game Data record. _Avoid_: Trigger as an entity
|
||||||
|
|
||||||
|
**Activation**: A successful mechanic occurrence after its Event, conditions,
|
||||||
|
chance, and Cooldown requirements resolve. _Avoid_: Proc when precision matters
|
||||||
|
|
||||||
|
**Cooldown**: The period after a successful Activation during which the same
|
||||||
|
mechanic cannot activate again. A failed chance does not begin the Cooldown.
|
||||||
|
_Avoid_: Delay
|
||||||
|
|
||||||
|
**Mechanic State**: A value retained across Events for one mechanic, such as a
|
||||||
|
flag, count, stack, duration, mark, or resource. _Avoid_: Runtime state when
|
||||||
|
discussing game behavior
|
||||||
|
|
||||||
|
**Outcome**: The effect emitted by an activated mechanic, such as an Attribute
|
||||||
|
change, damage, restoration, resource change, or marker. _Avoid_: Effect when
|
||||||
|
the source Talent and emitted result could be confused
|
||||||
|
|
||||||
|
**Combat Profile**: The encounter assumptions and observed rates used to project
|
||||||
|
a Loadout's combat performance. _Avoid_: Simulation when no event simulation
|
||||||
|
occurs
|
||||||
|
|
||||||
|
**Scenario Input**: An explicit encounter assumption required to evaluate a
|
||||||
|
mechanic whose result cannot be determined from the Loadout alone. _Avoid_:
|
||||||
|
Guess, default assumption
|
||||||
|
|
||||||
|
**Ready**: The mechanic readiness state indicating that typed Game Data can
|
||||||
|
evaluate the mechanic without additional Scenario Inputs. _Avoid_: Complete
|
||||||
|
|
||||||
|
**Requires Assumptions**: The mechanic readiness state indicating that
|
||||||
|
evaluation needs one or more explicit Scenario Inputs. _Avoid_: Ready,
|
||||||
|
unsupported
|
||||||
|
|
||||||
|
**Captured Only**: The mechanic readiness state indicating that SHD.build
|
||||||
|
preserves the authored behavior but cannot calculate it. _Avoid_: Unsupported
|
||||||
|
when the behavior has still been recorded
|
||||||
|
|
||||||
|
**Extra Health**: Temporary Health granted in addition to an Agent's normal
|
||||||
|
Health. Each grant remains distinct from later changes to the current Extra
|
||||||
|
Health total. _Avoid_: Health, healing, bonus armor
|
||||||
|
|
||||||
|
**Hit Taken**: A hit received by an Agent, including one absorbed by Extra
|
||||||
|
Health. A Hit Taken does not require normal Health loss. _Avoid_: Taking Damage
|
||||||
|
|
||||||
|
**Taking Damage**: A loss of an Agent's normal Health. A hit absorbed by Extra
|
||||||
|
Health does not count as Taking Damage. _Avoid_: Hit Taken
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
---
|
||||||
|
name: SHD.build
|
||||||
|
description: A precise tactical intelligence interface for The Division Resurgence community.
|
||||||
|
colors:
|
||||||
|
operations-black: "#0a0c0d"
|
||||||
|
terminal-black: "#0d0f10"
|
||||||
|
terminal-surface: "#111415"
|
||||||
|
terminal-graphite: "#141718"
|
||||||
|
active-graphite: "#15191a"
|
||||||
|
modeled-data-surface: "#0d1213"
|
||||||
|
structural-line: "#303536"
|
||||||
|
structural-line-soft: "#272c2d"
|
||||||
|
structural-line-strong: "#424849"
|
||||||
|
modeled-data-line: "#3b4648"
|
||||||
|
field-manual-white: "#ecebe5"
|
||||||
|
telemetry-muted: "#858b8d"
|
||||||
|
telemetry-dim: "#5d6567"
|
||||||
|
signal-orange: "#f6a821"
|
||||||
|
signal-orange-hover: "#ffc45d"
|
||||||
|
system-success: "#75c08c"
|
||||||
|
system-danger: "#df8069"
|
||||||
|
system-info: "#70b9d6"
|
||||||
|
quality-standard: "#69b66f"
|
||||||
|
quality-specialized: "#4f9ed8"
|
||||||
|
quality-superior: "#9b68d4"
|
||||||
|
quality-high-end: "#e4bd3f"
|
||||||
|
typography:
|
||||||
|
display:
|
||||||
|
fontFamily: "Barlow, Arial Narrow, Arial, Helvetica, sans-serif"
|
||||||
|
fontSize: "clamp(3rem, 7vw, 6rem)"
|
||||||
|
fontWeight: 700
|
||||||
|
lineHeight: 0.9
|
||||||
|
letterSpacing: "-0.04em"
|
||||||
|
headline:
|
||||||
|
fontFamily: "Barlow, Arial Narrow, Arial, Helvetica, sans-serif"
|
||||||
|
fontSize: "clamp(2.25rem, 5vw, 4.5rem)"
|
||||||
|
fontWeight: 700
|
||||||
|
lineHeight: 0.95
|
||||||
|
letterSpacing: "-0.03em"
|
||||||
|
title:
|
||||||
|
fontFamily: "Barlow, Arial Narrow, Arial, Helvetica, sans-serif"
|
||||||
|
fontSize: "1.1875rem"
|
||||||
|
fontWeight: 700
|
||||||
|
lineHeight: 1.1
|
||||||
|
letterSpacing: "0"
|
||||||
|
body:
|
||||||
|
fontFamily: "Barlow, Arial Narrow, Arial, Helvetica, sans-serif"
|
||||||
|
fontSize: "1rem"
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.55
|
||||||
|
letterSpacing: "normal"
|
||||||
|
editorial:
|
||||||
|
fontFamily: "Source Serif 4, Georgia, serif"
|
||||||
|
fontSize: "1rem"
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.65
|
||||||
|
letterSpacing: "normal"
|
||||||
|
label:
|
||||||
|
fontFamily: "Barlow, Arial Narrow, Arial, Helvetica, sans-serif"
|
||||||
|
fontSize: "0.75rem"
|
||||||
|
fontWeight: 900
|
||||||
|
lineHeight: 1.25
|
||||||
|
letterSpacing: "0.08em"
|
||||||
|
rounded:
|
||||||
|
engineered: "0px"
|
||||||
|
avatar: "50%"
|
||||||
|
spacing:
|
||||||
|
xs: "5px"
|
||||||
|
sm: "8px"
|
||||||
|
md: "12px"
|
||||||
|
lg: "20px"
|
||||||
|
panel: "24px"
|
||||||
|
components:
|
||||||
|
button-primary:
|
||||||
|
backgroundColor: "{colors.signal-orange}"
|
||||||
|
textColor: "{colors.operations-black}"
|
||||||
|
typography: "{typography.label}"
|
||||||
|
rounded: "{rounded.engineered}"
|
||||||
|
padding: "16px 22px"
|
||||||
|
height: "40px"
|
||||||
|
button-primary-hover:
|
||||||
|
backgroundColor: "{colors.signal-orange-hover}"
|
||||||
|
textColor: "{colors.operations-black}"
|
||||||
|
typography: "{typography.label}"
|
||||||
|
rounded: "{rounded.engineered}"
|
||||||
|
padding: "16px 22px"
|
||||||
|
button-secondary:
|
||||||
|
backgroundColor: "transparent"
|
||||||
|
textColor: "{colors.field-manual-white}"
|
||||||
|
typography: "{typography.label}"
|
||||||
|
rounded: "{rounded.engineered}"
|
||||||
|
padding: "16px 22px"
|
||||||
|
height: "40px"
|
||||||
|
input:
|
||||||
|
backgroundColor: "{colors.terminal-black}"
|
||||||
|
textColor: "{colors.field-manual-white}"
|
||||||
|
typography: "{typography.body}"
|
||||||
|
rounded: "{rounded.engineered}"
|
||||||
|
padding: "11px"
|
||||||
|
height: "40px"
|
||||||
|
panel:
|
||||||
|
backgroundColor: "{colors.terminal-graphite}"
|
||||||
|
textColor: "{colors.field-manual-white}"
|
||||||
|
rounded: "{rounded.engineered}"
|
||||||
|
padding: "{spacing.panel}"
|
||||||
|
assistant-panel:
|
||||||
|
backgroundColor: "{colors.modeled-data-surface}"
|
||||||
|
textColor: "{colors.field-manual-white}"
|
||||||
|
rounded: "{rounded.engineered}"
|
||||||
|
padding: "{spacing.md}"
|
||||||
|
status-chip:
|
||||||
|
backgroundColor: "{colors.active-graphite}"
|
||||||
|
textColor: "{colors.telemetry-muted}"
|
||||||
|
typography: "{typography.label}"
|
||||||
|
rounded: "{rounded.engineered}"
|
||||||
|
padding: "5px 7px"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design System: SHD.build
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Creative North Star: "The Strategic Operations Grid"**
|
||||||
|
|
||||||
|
SHD.build serves active agents through a dependable intelligence network:
|
||||||
|
precise, restrained, tactical, and built around trustworthy data. Alignment,
|
||||||
|
density, operational labels, and visible state carry the interface. Public
|
||||||
|
surfaces use space and larger editorial hierarchy. ISAC compresses the same
|
||||||
|
identity into an efficient control surface.
|
||||||
|
|
||||||
|
Fine structural lines, layered black and graphite surfaces, cropped game
|
||||||
|
iconography, clipped silhouettes, and tactile state changes give the interface
|
||||||
|
its technical character. Theme language supports comprehension and avoids
|
||||||
|
military cosplay.
|
||||||
|
|
||||||
|
**Key Characteristics:**
|
||||||
|
|
||||||
|
- Dark, high-contrast operational canvas with restrained tonal layering.
|
||||||
|
- Square engineered geometry, except circular agent avatars and the SHD mark.
|
||||||
|
- Dense uppercase telemetry balanced by readable editorial descriptions.
|
||||||
|
- Signal Orange used sparingly for action, focus, selection, and important data.
|
||||||
|
- Explicit textual states paired with icons, color, and motion.
|
||||||
|
- Responsive density from mobile touch layouts through scaled 4K workspaces.
|
||||||
|
|
||||||
|
## Brand Identity
|
||||||
|
|
||||||
|
SHD.build uses the two-color Core Segments identity. The emblem connects the
|
||||||
|
orange Division signal with ISAC's role as the site's analytic system:
|
||||||
|
|
||||||
|
- Three unequal Signal Orange arc segments form the outer circle. Each segment
|
||||||
|
has square ends.
|
||||||
|
- The widest break sits in the upper-right quadrant and contains one detached
|
||||||
|
Signal Orange square, which represents a data packet.
|
||||||
|
- A Field Manual White open square forms the ISAC core at the center.
|
||||||
|
|
||||||
|
Keep the emblem in Signal Orange and Field Manual White on Operations Black,
|
||||||
|
Terminal Black, or another approved dark surface. SHD.build has no one-color
|
||||||
|
emblem. Do not close the arc gaps, equalize the segments, remove the data
|
||||||
|
packet, fill the ISAC core, add letters to the emblem, or recolor either part.
|
||||||
|
If a placement cannot reproduce both colors, use a dark holding surface or omit
|
||||||
|
the emblem.
|
||||||
|
|
||||||
|
The standard lockup pairs the emblem with the heavy angular `SHD.build`
|
||||||
|
wordmark. Set `SHD` in Field Manual White and `.build` in Signal Orange. Place
|
||||||
|
the uppercase descriptor `RESURGENCE DATA & TOOLS` below the wordmark in
|
||||||
|
restrained telemetry type. Do not substitute another descriptor.
|
||||||
|
|
||||||
|
Use the complete lockup in the 76px global header and shared footer. The header
|
||||||
|
uses a 38px emblem; keep the wordmark and descriptor compact enough to preserve
|
||||||
|
the navigation grid. Mobile headers may use the emblem alone when the complete
|
||||||
|
lockup would crowd navigation. Favicons and app icons use the emblem without
|
||||||
|
text. Small-size artwork must retain both colors, widen the arc gaps where the
|
||||||
|
pixel grid requires it, and keep the packet and open core distinct.
|
||||||
|
|
||||||
|
Treat the emblem as decorative when visible `SHD.build` text names the product.
|
||||||
|
Give an emblem-only home link the accessible name `SHD.build home`. Preserve
|
||||||
|
the approved colors and full opacity in focus, disabled, and compact contexts;
|
||||||
|
use text or control styling to communicate state instead of fading the mark.
|
||||||
|
The Field Manual White core and Signal Orange arcs must remain distinct from
|
||||||
|
their background at every delivered size.
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
The palette uses Operations Black, Terminal Graphite, Field Manual White, rare
|
||||||
|
Signal Orange, and semantic system and item-quality signals.
|
||||||
|
|
||||||
|
### Primary
|
||||||
|
|
||||||
|
- **Signal Orange** (`#f6a821`): Primary actions, current navigation, focus, selected
|
||||||
|
controls, important values, and SHD identity details.
|
||||||
|
- **Signal Orange Hover** (`#ffc45d`): Confirms that an orange action accepts input; never
|
||||||
|
use it as a decorative accent.
|
||||||
|
|
||||||
|
### Secondary
|
||||||
|
|
||||||
|
- **System Success** (`#75c08c`): Completed, approved, online, and healthy states.
|
||||||
|
- **System Danger** (`#df8069`): Failed, rejected, blocked, destructive, and fault states.
|
||||||
|
- **System Info** (`#70b9d6`): Informational system state that is neither action nor
|
||||||
|
success.
|
||||||
|
|
||||||
|
### Tertiary
|
||||||
|
|
||||||
|
- **Quality Standard** (`#69b66f`), **Specialized** (`#4f9ed8`),
|
||||||
|
**Superior** (`#9b68d4`), and **High-end** (`#e4bd3f`): Stable
|
||||||
|
game-semantic quality colors. They identify item quality and never replace
|
||||||
|
a textual quality label.
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
|
||||||
|
- **Operations Black** (`#0a0c0d`): The persistent application canvas.
|
||||||
|
- **Terminal Black** (`#0d0f10`): Recessed fields and data wells.
|
||||||
|
- **Terminal Surface** (`#111415`): Navigation, registry, and low-level panel surfaces.
|
||||||
|
- **Terminal Graphite** (`#141718`): Raised working panels and cards.
|
||||||
|
- **Active Graphite** (`#15191a`): Hovered, selected, and locally emphasized surfaces.
|
||||||
|
- **Modeled Data Surface** (`#0d1213`): Recessed mechanic programs, calculation revisions,
|
||||||
|
generated script, and AI-assisted drafting workspaces.
|
||||||
|
- **Structural Line** (`#303536`) / **Structural Line Soft** (`#272c2d`): Primary and subordinate
|
||||||
|
separators that create hierarchy without elevation.
|
||||||
|
- **Structural Line Strong** (`#424849`) / **Modeled Data Line** (`#3b4648`): Interactive boundaries and
|
||||||
|
blue-railed modeled-data panels that need more separation from the registry.
|
||||||
|
- **Field Manual White** (`#ecebe5`): Primary readable content.
|
||||||
|
- **Telemetry Muted** (`#858b8d`) / **Telemetry Dim** (`#5d6567`): Secondary labels and low-priority
|
||||||
|
operational metadata.
|
||||||
|
|
||||||
|
**The Orange Discipline Rule.** Use Signal Orange for action and emphasis. Keep
|
||||||
|
it out of large decorative areas and away from semantic state colors.
|
||||||
|
|
||||||
|
**The Redundant Meaning Rule.** Pair color with text, position, shape, or an
|
||||||
|
icon. A status or quality cannot depend on hue alone.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
**Display Font:** Barlow (with Arial Narrow, Arial, Helvetica, and
|
||||||
|
sans-serif fallbacks)
|
||||||
|
**Body Font:** Barlow (with Arial Narrow, Arial, Helvetica, and
|
||||||
|
sans-serif fallbacks)
|
||||||
|
**Editorial Font:** Source Serif 4 (with Georgia and serif fallbacks)
|
||||||
|
|
||||||
|
**Character:** Barlow gives the system a squared, compact, engineered voice
|
||||||
|
that remains readable in dense interfaces. Reserve Source Serif 4 for
|
||||||
|
explanations, guide-like prose, and selected descriptive passages. Keep
|
||||||
|
operational data distinct from editorial knowledge.
|
||||||
|
|
||||||
|
### Hierarchy
|
||||||
|
|
||||||
|
- **Display** (700, fluid up to 6rem, 0.9 line-height): Short public hero
|
||||||
|
statements and major exception messages; never dense application copy.
|
||||||
|
- **Headline** (700, fluid up to 4.5rem, 0.95 line-height): Primary routed-page
|
||||||
|
identity and strong editorial section titles.
|
||||||
|
- **Title** (700, 1.1875rem, 1.1 line-height): Panel, card, record, and module
|
||||||
|
names.
|
||||||
|
- **Body** (400, 1rem, 1.55 line-height): Functional copy and general content;
|
||||||
|
long passages stay near 65–75 characters per line.
|
||||||
|
- **Editorial** (400, 1rem, 1.65 line-height): Explanations, introductions, and
|
||||||
|
long-form descriptions.
|
||||||
|
- **Label** (900, 0.75rem, 0.08em tracking): Uppercase telemetry, field labels,
|
||||||
|
statuses, metadata, and compact actions.
|
||||||
|
|
||||||
|
**The Two-Voice Rule.** Sans-serif operates the system; serif explains it. Do
|
||||||
|
not use monospace to make an interface look technical.
|
||||||
|
|
||||||
|
**The Telemetry Restraint Rule.** Uppercase tracked labels are short navigational
|
||||||
|
or state-bearing signals, not paragraph typography.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
The system uses an aligned grid of operational regions. Public pages use
|
||||||
|
centered containers up to 1640px with responsive outer padding. ISAC uses a
|
||||||
|
compact command header, sticky 210px module rail, and flexible registry
|
||||||
|
workspace. Builder and account surfaces use workbench columns that collapse
|
||||||
|
into a task-appropriate mobile sequence.
|
||||||
|
|
||||||
|
Spacing follows a tight 5/8/12/20px rhythm with 24px as the standard panel
|
||||||
|
inset. Related controls stay close; modules and major sections receive clearer
|
||||||
|
separation. One-pixel borders can form contiguous matrices so related options
|
||||||
|
read as one instrument.
|
||||||
|
|
||||||
|
The global header is 76px. At 900px and below, desktop navigation becomes an
|
||||||
|
explicit mobile navigation layer and multi-column operational layouts collapse.
|
||||||
|
At 700px and below, tables, filter matrices, action groups, and editors become
|
||||||
|
touch-oriented stacks or controlled horizontal registries. Interactive mobile
|
||||||
|
targets are at least 44px where space permits.
|
||||||
|
|
||||||
|
Large displays use the shared display-scale progression: 1.15 at 2200px, 1.3 at
|
||||||
|
2800px, and 1.5 at 3400px. The scale preserves readable density and avoids
|
||||||
|
empty space.
|
||||||
|
|
||||||
|
**The Connected Grid Rule.** Use borders, shared edges, and aligned tracks to
|
||||||
|
show relationships. Do not wrap every subsection in another card.
|
||||||
|
|
||||||
|
**The Operational Density Rule.** Public discovery may breathe; ISAC and data
|
||||||
|
workbenches prioritize scanability and efficient repeated actions.
|
||||||
|
|
||||||
|
## Elevation & Depth
|
||||||
|
|
||||||
|
SHD.build uses structural layering. Depth comes from tonal surface changes,
|
||||||
|
fine borders, clipped corners, inset active rails, and controlled overlays.
|
||||||
|
Resting cards and panels do not float. Use shadows for temporary surfaces that
|
||||||
|
detach from the document: menus, dialogs, toasts, and routed fault consoles.
|
||||||
|
Give each a clear downward offset and soft black blur.
|
||||||
|
|
||||||
|
### Shadow Vocabulary
|
||||||
|
|
||||||
|
- **Detached Menu:** `0 18px 46px #000a` for account and compact floating menus.
|
||||||
|
- **Operational Dialog:** `0 18px 60px #000` for focused quick views and review
|
||||||
|
dialogs.
|
||||||
|
- **Transient Feedback:** `0 12px 30px #000b` for toast notifications.
|
||||||
|
- **Fault Console:** `0 24px 70px #0008` for routed exception surfaces.
|
||||||
|
|
||||||
|
**The Structural Layering Rule.** Borders and tones establish permanent
|
||||||
|
hierarchy; shadows communicate temporary detachment.
|
||||||
|
|
||||||
|
**The No Ambient Card Rule.** Ordinary panels and cards remain shadowless at
|
||||||
|
rest.
|
||||||
|
|
||||||
|
## Shapes
|
||||||
|
|
||||||
|
The default radius is zero. Buttons, fields, panels, cards, filters, badges,
|
||||||
|
dialogs, and navigation elements use square engineered geometry with one-pixel
|
||||||
|
structural borders. Select hero visuals and status consoles may use clipped
|
||||||
|
polygon corners when the silhouette reinforces an operational artifact.
|
||||||
|
|
||||||
|
Circular geometry identifies agent avatars, the SHD mark, compact online
|
||||||
|
indicators, and radial or orbital data graphics. Do not use pills as a general
|
||||||
|
control shape.
|
||||||
|
|
||||||
|
**The Engineered Edge Rule.** Square is the default and rounding must have
|
||||||
|
semantic justification.
|
||||||
|
|
||||||
|
**The Identity Circle Rule.** Circular crops belong to people, the SHD emblem,
|
||||||
|
and radial information, not arbitrary controls.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
Components feel tactile and technical. A press produces physical feedback; a
|
||||||
|
request exposes pending text and blocks repeat activation; focus remains clear
|
||||||
|
without ornament.
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
- **Shape:** Square with no radius and compact uppercase label typography.
|
||||||
|
- **Primary:** Signal Orange with Operations Black text and strong weight.
|
||||||
|
- **Secondary:** Transparent or Terminal Surface with Field Manual White text
|
||||||
|
and a Structural Line border.
|
||||||
|
- **Danger:** Dark red-toned surface or restrained danger text; destructive
|
||||||
|
confirmation remains explicit.
|
||||||
|
- **Hover / Focus:** Brighter border, text, or surface using the control's
|
||||||
|
semantic color. Focus-visible uses the thin Signal Orange outline.
|
||||||
|
- **Pressed / Pending:** Active controls move down 1–2px, subtly scale, and
|
||||||
|
darken. Pending controls change their label, set `aria-busy`, and disable
|
||||||
|
duplicate activation.
|
||||||
|
|
||||||
|
### Chips
|
||||||
|
|
||||||
|
- **Style:** Square, compact, bordered telemetry labels. Selection uses
|
||||||
|
Signal Orange or the relevant semantic color plus explicit selected text or
|
||||||
|
state.
|
||||||
|
- **State:** Multi-select chips remain individually removable; pending
|
||||||
|
contributor references say `PENDING` and identify their provenance.
|
||||||
|
|
||||||
|
### Cards / Containers
|
||||||
|
|
||||||
|
- **Corner Style:** Square engineered edge.
|
||||||
|
- **Background:** Terminal Surface or Terminal Graphite according to hierarchy.
|
||||||
|
- **Shadow Strategy:** None at rest; see Structural Layering for overlays.
|
||||||
|
- **Border:** One-pixel Structural Line, with a two-pixel top signal only when a
|
||||||
|
meaningful category or state owns it.
|
||||||
|
- **Internal Padding:** Usually 20–24px on public cards and tighter 8–14px on
|
||||||
|
dense data registries.
|
||||||
|
|
||||||
|
### Inputs / Fields
|
||||||
|
|
||||||
|
- **Style:** Recessed Terminal Black fill, one-pixel Structural Line border,
|
||||||
|
Field Manual White value, and compact uppercase label above.
|
||||||
|
- **Focus:** Signal Orange border and a one-pixel focus-visible outline with a
|
||||||
|
one-pixel offset.
|
||||||
|
- **Numeric Entry:** Existing content selects on focus for efficient
|
||||||
|
replacement.
|
||||||
|
- **Error / Disabled:** Error state uses System Danger with recovery text.
|
||||||
|
Disabled and pending states remain readable, expose why they are unavailable,
|
||||||
|
and prevent duplicate requests.
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
Desktop global navigation uses centered tracked labels with a thin orange
|
||||||
|
active baseline. The authenticated agent menu combines circular identity,
|
||||||
|
online state, unread count, and an explicit expanded marker. Mobile navigation
|
||||||
|
becomes a numbered, full-height operational list with visible current state and
|
||||||
|
notification counts.
|
||||||
|
|
||||||
|
ISAC navigation uses a sticky registry rail with aligned index cells, module
|
||||||
|
labels, review badges, and an inset orange current-state bar. Permission and
|
||||||
|
mode indicators remain visible in the command header.
|
||||||
|
|
||||||
|
### Segmented Choice
|
||||||
|
|
||||||
|
Two-to-six bounded choices render as one square matrix instead of a dropdown.
|
||||||
|
Radio semantics permit one active value; checkbox semantics permit additive
|
||||||
|
selection. Game-semantic icons may replace repeated text when their meaning is
|
||||||
|
clear, but accessible labels remain present.
|
||||||
|
|
||||||
|
### Searchable Assignment
|
||||||
|
|
||||||
|
Large or relational option sets use a searchable assignment instrument with a
|
||||||
|
selected summary, removable assignments, live match count, and an explicit
|
||||||
|
result action. Pending records remain usable and visibly attributed.
|
||||||
|
|
||||||
|
### Modeled Authoring Assist
|
||||||
|
|
||||||
|
Mechanic and calculation drafting helpers use a compact Modeled Data Surface
|
||||||
|
with a blue information rail. Their headers pair a plain-language task name
|
||||||
|
with an explicit `AUTHORIZED` or `PERMISSION REQUIRED` state. Authors without
|
||||||
|
permission retain the complete manual editor.
|
||||||
|
|
||||||
|
The expanded workspace keeps source wording or calculation requirements
|
||||||
|
visible while it presents one result state: pending analysis, clarification,
|
||||||
|
capability gap, failure, or draft preview. A generated draft never changes the
|
||||||
|
form until the author confirms Apply. Save, proposal, publication, and
|
||||||
|
activation remain separate actions in the owning editor.
|
||||||
|
|
||||||
|
### Registry and Database Cards
|
||||||
|
|
||||||
|
Registry rows prioritize names, durable statuses, relationships, and edit
|
||||||
|
actions within consistent columns. Public database cards use an identity
|
||||||
|
header, icon, metadata, compact fact matrices, and optional relationship icon
|
||||||
|
rails. Facts with no applicable value stay hidden.
|
||||||
|
|
||||||
|
### Toasts and Dialogs
|
||||||
|
|
||||||
|
Toasts are transient, top-right detached panels with semantic top and side
|
||||||
|
signals, clear titles, concise recovery text, and dismiss controls. Dialogs use
|
||||||
|
a darkened backdrop, orange top rule, structured header, constrained scroll
|
||||||
|
region, and explicit confirmation actions. Reduced-motion preferences remove
|
||||||
|
entrance animation.
|
||||||
|
|
||||||
|
## Do's and Don'ts
|
||||||
|
|
||||||
|
### Do:
|
||||||
|
|
||||||
|
- **Do** reserve Signal Orange for action, focus, selection, and important
|
||||||
|
operational values.
|
||||||
|
- **Do** build hierarchy with connected grids, fine borders, tonal layers, and
|
||||||
|
concise labels.
|
||||||
|
- **Do** pair every state color and icon with explicit text.
|
||||||
|
- **Do** use reusable segmented choices for bounded sets and searchable
|
||||||
|
assignments for large relational sets.
|
||||||
|
- **Do** preserve tactile pressed, focus-visible, pending, disabled, error,
|
||||||
|
success, and reduced-motion behavior.
|
||||||
|
- **Do** keep AI-generated data inside an explicit draft-and-review boundary
|
||||||
|
while preserving manual authoring controls.
|
||||||
|
- **Do** let public editorial surfaces breathe while keeping ISAC dense and
|
||||||
|
task-oriented.
|
||||||
|
|
||||||
|
### Don't:
|
||||||
|
|
||||||
|
- **Don't** use camouflage, distressed textures, decorative weapon imagery, or
|
||||||
|
tactical ornament in utility interfaces.
|
||||||
|
- **Don't** introduce rounded friendly-SaaS cards, decorative pills, glass
|
||||||
|
panels, gradient text, or ambient card shadows.
|
||||||
|
- **Don't** cover large areas in orange or use glow as a substitute for
|
||||||
|
hierarchy.
|
||||||
|
- **Don't** turn every section into equal icon-heading-description cards or
|
||||||
|
nest cards inside cards.
|
||||||
|
- **Don't** use monospace as a technical costume or tracked uppercase type for
|
||||||
|
long-form copy.
|
||||||
|
- **Don't** let operational terminology obscure the action, consequence,
|
||||||
|
recovery, or state it describes.
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM node:24-bookworm-slim AS dependencies
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json .npmrc ./
|
||||||
|
COPY scripts/prepare.mjs ./scripts/prepare.mjs
|
||||||
|
RUN npm ci --ignore-scripts && npm rebuild argon2
|
||||||
|
|
||||||
|
FROM dependencies AS build
|
||||||
|
COPY vite.config.ts tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY static ./static
|
||||||
|
RUN npm run build:app
|
||||||
|
|
||||||
|
FROM node:24-bookworm-slim AS runtime-dependencies
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json .npmrc ./
|
||||||
|
COPY scripts/prepare.mjs ./scripts/prepare.mjs
|
||||||
|
RUN npm ci --omit=dev --ignore-scripts && npm rebuild argon2 && npm cache clean --force
|
||||||
|
|
||||||
|
FROM node:24-bookworm-slim AS runtime
|
||||||
|
ARG TARGETARCH=amd64
|
||||||
|
ARG INFISICAL_CLI_VERSION=0.43.116
|
||||||
|
LABEL org.opencontainers.image.title="shd-build"
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
HOST=0.0.0.0 \
|
||||||
|
PORT=3000 \
|
||||||
|
INFISICAL_DOMAIN=https://secrets.jb.codes \
|
||||||
|
INFISICAL_DISABLE_UPDATE_CHECK=true \
|
||||||
|
TDR_DATABASE_PATH=/app/data/shd-build.db \
|
||||||
|
TDR_AVATAR_STORAGE_PATH=/app/data/avatars \
|
||||||
|
TDR_GAME_ICON_STORAGE_PATH=/app/data/game-icons
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl fonts-dejavu-core gosu \
|
||||||
|
&& useradd --uid 10001 --gid node --no-create-home --shell /usr/sbin/nologin shd-runtime \
|
||||||
|
&& case "$TARGETARCH" in \
|
||||||
|
amd64) infisical_sha256=53abc96a3bc872ade81efe156275bd30c53f9ee8b52965b0c9cb320a9c814fbc ;; \
|
||||||
|
arm64) infisical_sha256=0111d8e7daa5f3c498a8cc6cb9852bde2c5c21e53c935d4738ddd80c764554cc ;; \
|
||||||
|
*) echo "Unsupported Infisical CLI architecture: $TARGETARCH" >&2; exit 1 ;; \
|
||||||
|
esac \
|
||||||
|
&& curl -fsSL "https://github.com/Infisical/cli/releases/download/v${INFISICAL_CLI_VERSION}/cli_${INFISICAL_CLI_VERSION}_linux_${TARGETARCH}.tar.gz" -o /tmp/infisical.tar.gz \
|
||||||
|
&& echo "$infisical_sha256 /tmp/infisical.tar.gz" | sha256sum -c - \
|
||||||
|
&& tar -xzf /tmp/infisical.tar.gz -C /usr/local/bin infisical \
|
||||||
|
&& chmod 755 /usr/local/bin/infisical \
|
||||||
|
&& rm -f /tmp/infisical.tar.gz \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=runtime-dependencies --chown=node:node /app/node_modules ./node_modules
|
||||||
|
COPY --from=build --chown=node:node /app/build ./build
|
||||||
|
COPY --chown=node:node database ./database
|
||||||
|
COPY --chown=node:node scripts ./scripts
|
||||||
|
COPY --chown=node:node deploy/container-entrypoint.sh ./deploy/container-entrypoint.sh
|
||||||
|
RUN chmod 755 ./deploy/container-entrypoint.sh ./scripts/runtime-launcher.mjs && mkdir -p /app/data && chown node:node /app/data && chmod 0770 /app/data
|
||||||
|
EXPOSE 3000
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
ENTRYPOINT ["./deploy/container-entrypoint.sh"]
|
||||||
|
CMD ["serve"]
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
<!-- impeccable:product-schema 1 -->
|
||||||
|
|
||||||
|
## Platform
|
||||||
|
|
||||||
|
web
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
SHD.build serves active _The Division Resurgence_ players who need reliable game
|
||||||
|
data, accurate loadout planning, build sharing, and community guides.
|
||||||
|
|
||||||
|
Registered players can save and share builds, publish guides, participate in
|
||||||
|
community interactions, and manage their identity and notifications. Anonymous
|
||||||
|
players can explore public content and keep local builder drafts.
|
||||||
|
|
||||||
|
Trusted contributors maintain structured game data through ISAC. Oversight
|
||||||
|
reviewers validate proposed changes. Moderators handle community safety, while
|
||||||
|
administrators manage users, roles, system configuration, and operations.
|
||||||
|
|
||||||
|
## Product Purpose
|
||||||
|
|
||||||
|
SHD.build is an independent community intelligence platform for _The Division
|
||||||
|
Resurgence_. It includes a public game database, calculated loadout builder,
|
||||||
|
shared builds, guides, news, and community participation.
|
||||||
|
|
||||||
|
Players should understand game systems, create accurate loadouts, learn from
|
||||||
|
other agents, and help keep information current as the game changes.
|
||||||
|
|
||||||
|
## Positioning
|
||||||
|
|
||||||
|
SHD.build uses one community-maintained, structured game-data source for its
|
||||||
|
public database, builder calculations, builds, guides, and contribution
|
||||||
|
workflow. ISAC lets non-technical contributors propose connected data changes.
|
||||||
|
Oversight reviews proposals before they become authoritative.
|
||||||
|
|
||||||
|
Player-facing planning and reference experiences use the same moderated data.
|
||||||
|
|
||||||
|
## Operating Context
|
||||||
|
|
||||||
|
Players use the site to plan characters, compare drops, reproduce in-game
|
||||||
|
equipment, browse community recommendations, and respond to game updates. The
|
||||||
|
interface must work from mobile devices through high-resolution desktop
|
||||||
|
displays.
|
||||||
|
|
||||||
|
The principal public channels are News, Database, Builds, Guides, and Builder.
|
||||||
|
Account management includes authentication methods, profile identity, avatars,
|
||||||
|
notifications, created content, favorites, and privacy controls.
|
||||||
|
|
||||||
|
ISAC is the operational console for game-data entry, contribution review, user
|
||||||
|
and role management, moderation, audit history, automated messages, and system
|
||||||
|
metrics. Registered agents begin with read-only access and can enable
|
||||||
|
contributor mode after accepting the contributor guidelines. Contributors can
|
||||||
|
connect proposed records to pending records before approval.
|
||||||
|
|
||||||
|
## Capabilities and Constraints
|
||||||
|
|
||||||
|
- The application is built with SvelteKit and Svelte.
|
||||||
|
- Persistent application data uses the embedded `@tursodatabase/database`
|
||||||
|
package. Turso Cloud is not required.
|
||||||
|
- Development, staging, and production use distinct databases. Embedded database
|
||||||
|
files and uploaded assets are persistent user data and must survive
|
||||||
|
deployments and additive schema migrations.
|
||||||
|
- Authentication supports passkeys, emailed magic links, Discord OAuth, and
|
||||||
|
optional username/password credentials. An account must always retain at least
|
||||||
|
one usable sign-in method.
|
||||||
|
- Anonymous builder drafts may use local storage. Database-backed sharing
|
||||||
|
requires an account.
|
||||||
|
- Saved builds support private, unlisted, and public visibility.
|
||||||
|
- User deletion is soft deletion so authored builds and guides can remain while
|
||||||
|
the system releases login identities and reusable usernames safely.
|
||||||
|
- The application normalizes user-uploaded avatars for storage and sends them to
|
||||||
|
an Oversight queue without blocking immediate use.
|
||||||
|
- ISAC mutations are permission-controlled, append-only audited, and use
|
||||||
|
moderation proposals when the operator lacks direct-write authority.
|
||||||
|
- Game-data editors must preserve the standardized add/edit lifecycle described
|
||||||
|
in `AGENTS.md`.
|
||||||
|
- Major data changes preserve contributor-entered data through verified backups
|
||||||
|
and additive migrations with minimal development-server downtime.
|
||||||
|
- SHD.build is an independent fan site. Ubisoft and the relevant owners retain
|
||||||
|
rights to _Tom Clancy's The Division_, _The Division Resurgence_, and their
|
||||||
|
associated intellectual property and assets.
|
||||||
|
|
||||||
|
## Brand Commitments
|
||||||
|
|
||||||
|
The product name is `SHD.build`, with `SHD` capitalized. The product serves the
|
||||||
|
full _The Division Resurgence_ community, including loadout planning.
|
||||||
|
|
||||||
|
Use restrained in-universe operational language such as agent, ISAC, Oversight,
|
||||||
|
intelligence, access, transmission, and registry when it improves comprehension.
|
||||||
|
Do not let the theme obscure plain meaning or turn into military cosplay.
|
||||||
|
|
||||||
|
The existing SHD logo, favicon, game-type icon library, quality colors, and
|
||||||
|
provider branding are maintained assets. Discord, Google, Apple, and Steam
|
||||||
|
identity controls must follow their respective provider requirements.
|
||||||
|
|
||||||
|
## Evidence on Hand
|
||||||
|
|
||||||
|
- `docs/DESIGN_SYSTEM.md` defines the current visual and interaction language.
|
||||||
|
- `docs/architecture.md` documents the application architecture and staged
|
||||||
|
feature model.
|
||||||
|
- `docs/STAT_LEVEL_SCALING.md` contains empirically derived equipment-scaling
|
||||||
|
rules used by builder calculations.
|
||||||
|
- `docs/DEPLOYMENT.md` documents containerized staging and production
|
||||||
|
operations.
|
||||||
|
- `src/routes` contains working public, account, builder, database, ISAC,
|
||||||
|
notification, contact, legal, and authentication surfaces.
|
||||||
|
- `static` contains the SHD favicon, provider marks, equipment and weapon
|
||||||
|
category icons, attribute icons, specialization icons, and shared fallback
|
||||||
|
artwork.
|
||||||
|
- The embedded development database contains contributor-entered game and user
|
||||||
|
data. Treat it as evidence and persistent user data rather than a disposable
|
||||||
|
fixture.
|
||||||
|
- No testimonials, player-count claims, official Ubisoft endorsement, or
|
||||||
|
performance benchmarks are established; future work must not fabricate them.
|
||||||
|
|
||||||
|
## Product Principles
|
||||||
|
|
||||||
|
1. **One source of operational truth.** Keep public reference data, builder
|
||||||
|
calculations, and contributed updates connected.
|
||||||
|
2. **Make deep systems understandable.** Present dense game mechanics with
|
||||||
|
precision without requiring users to understand the underlying data model.
|
||||||
|
3. **Contribution without technical gatekeeping.** Let players improve the
|
||||||
|
database through clear forms, dependencies, review status, and actionable
|
||||||
|
Oversight feedback.
|
||||||
|
4. **Trust through visible governance.** Use permissions, moderation, audit
|
||||||
|
history, privacy, and system messages to make consequential states and
|
||||||
|
actions explicit.
|
||||||
|
5. **Preserve the community's work.** Keep user content, contributor-entered
|
||||||
|
data, and operational history through deployments, migrations, archival, and
|
||||||
|
account lifecycle changes when policy permits.
|
||||||
|
|
||||||
|
## Accessibility & Inclusion
|
||||||
|
|
||||||
|
SHD.build targets WCAG 2.2 AA. Interfaces must support keyboard navigation,
|
||||||
|
clear focus-visible states, screen-reader semantics, sufficient contrast,
|
||||||
|
reduced-motion preferences, and layouts that work from mobile screens through 4K
|
||||||
|
desktop displays.
|
||||||
|
|
||||||
|
State and meaning cannot depend on color, icons, hover, or motion alone.
|
||||||
|
Controls need usable touch targets. User identity must support non-ASCII display
|
||||||
|
names and diverse avatar representation.
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
# SHD.build
|
# SHD.build
|
||||||
|
|
||||||
A SvelteKit application for creating and sharing loadouts for *The Division Resurgence*.
|
A SvelteKit community application for _The Division Resurgence_. SHD.build
|
||||||
|
combines a public game database, loadout builder, shared builds, guides, account
|
||||||
|
management, and the ISAC data-management console.
|
||||||
|
|
||||||
The first increment includes a responsive discovery page, an anonymous builder draft saved to local storage, authentication UI boundaries, an admin data-management preview, and an embedded Turso database foundation. See `docs/architecture.md` for the staged implementation plan.
|
See `docs/architecture.md` for the implementation model.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
The server uses the in-process `@tursodatabase/database` package with a local
|
The server uses the in-process `@tursodatabase/database` package and a local
|
||||||
SQLite-compatible file—there is no Turso Cloud dependency.
|
SQLite-compatible file. Turso Cloud is not required.
|
||||||
|
|
||||||
Create or update the local database:
|
Create or update the local database:
|
||||||
|
|
||||||
@@ -15,13 +17,12 @@ Create or update the local database:
|
|||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
The default file is `data/shd-build.db`. Copy `.env.example` to `.env` to
|
The Infisical `dev` environment sets the local file to
|
||||||
override its location.
|
`./data/shd-build-development.db`.
|
||||||
|
|
||||||
Custom avatars are stored as metadata-free 256 × 256 WebP files. Set
|
Store custom avatars as metadata-free 256 × 256 WebP files. Set
|
||||||
`TDR_AVATAR_STORAGE_PATH` to a persistent, writable directory in production;
|
`TDR_AVATAR_STORAGE_PATH` to a persistent, writable directory in production; it
|
||||||
it defaults to `data/avatars`. The same configuration works on Windows and
|
defaults to `data/avatars`. The same configuration works on Windows and Linux.
|
||||||
Linux.
|
|
||||||
|
|
||||||
Seed the initial local accounts once:
|
Seed the initial local accounts once:
|
||||||
|
|
||||||
@@ -29,9 +30,10 @@ Seed the initial local accounts once:
|
|||||||
npm run db:seed-users
|
npm run db:seed-users
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates administrator `Aewyn` and standard user `Quartermaster`. Strong
|
The seed creates `Aewyn` and `Quartermaster` with the standard user role. It
|
||||||
temporary passwords are generated and displayed once; only salted Argon2id
|
generates and displays strong temporary passwords once, then stores only salted
|
||||||
hashes are stored.
|
Argon2id hashes. The separate bootstrap policy promotes the first authenticated
|
||||||
|
account when no administrator exists.
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
@@ -39,42 +41,55 @@ hashes are stored.
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Encrypted environment files
|
## Infisical secrets
|
||||||
|
|
||||||
Development and production settings live in the committed, encrypted
|
The committed `.infisical.json` connects the project to the self-hosted
|
||||||
`.env.development` and `.env.production` files. Private decryption keys live
|
Infisical instance at `https://secrets.jb.codes`. Authenticate the local CLI
|
||||||
only in the ignored `.env.keys` file; back it up in a password manager.
|
once. The CLI uses its configured system vault and can fall back to an encrypted
|
||||||
|
file vault when the operating-system keyring is unavailable.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run env:decrypt # decrypt both files for editing
|
npx infisical login --domain=https://secrets.jb.codes
|
||||||
npm run env:encrypt # encrypt both files again
|
npm run dev
|
||||||
npm run env:stage # encrypt and git-add both files
|
|
||||||
npm run env:decrypt:development # operate on one environment
|
|
||||||
npm run env:decrypt:production
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The repository pre-commit hook runs `env:stage`, so a commit always replaces
|
Local commands use the `dev` environment. Application configuration lives under
|
||||||
staged plaintext environment files with encrypted versions. Git does not expose
|
`/app`; the scheduler bearer token lives under `/shared/scheduler`. Staging and
|
||||||
a pre-add hook, so use `npm run env:stage` instead of directly adding these two
|
production commands select the `staging` and `prod` environments explicitly.
|
||||||
files when possible.
|
`npm run check`, `npm test`, and `npm run build` do not contact Infisical.
|
||||||
|
|
||||||
Never commit or lose `.env.keys`. Production should receive
|
Staging and production containers authenticate at startup with separate
|
||||||
`DOTENV_PRIVATE_KEY_PRODUCTION` through its secret manager rather than copying
|
Universal Auth identities. Gitea stores each client secret, while repository
|
||||||
the keys file onto the server.
|
variables hold the matching non-secret client IDs. The containers fetch current
|
||||||
|
values before migration or startup and stop if authentication or validation
|
||||||
|
fails.
|
||||||
|
|
||||||
|
## Containers and staging deployment
|
||||||
|
|
||||||
|
The application uses the official SvelteKit Node adapter and a multi-stage
|
||||||
|
`Dockerfile`. Keep database files, database backups, user avatars, and uploaded
|
||||||
|
game-data icons under `/app/data` on a persistent volume. Inject runtime secrets
|
||||||
|
when the container starts; keep them out of the image.
|
||||||
|
|
||||||
|
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) documents the staging and production
|
||||||
|
Compose topologies. A push to `main` triggers both deployment workflows. Each
|
||||||
|
workflow validates Infisical access and the candidate image while the current
|
||||||
|
service stays online, then stops its application and scheduler for backup and
|
||||||
|
migration.
|
||||||
|
|
||||||
## Passwordless authentication
|
## Passwordless authentication
|
||||||
|
|
||||||
Passkeys are the preferred sign-in method. `APP_ORIGIN` must exactly match the
|
Use passkeys as the preferred sign-in method. Set `APP_ORIGIN` to the exact
|
||||||
public origin used for registration and authentication, while `PASSKEY_RP_ID`
|
public origin for registration and authentication. Set `PASSKEY_RP_ID` to that
|
||||||
must be that hostname or a registrable parent domain. The hosted environments
|
hostname or a registrable parent domain. Hosted environments use `shd.build`,
|
||||||
use `shd.build`, allowing credentials on both `shd.build` and
|
which permits credentials on `shd.build`, `stage.shd.build`, and
|
||||||
`dev.shd.build`. Direct localhost development should use `localhost`.
|
`dev.shd.build`. Use `localhost` for direct local development.
|
||||||
|
|
||||||
Magic links require `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`,
|
Magic links require `SMTP_HOST` and `SMTP_FROM`. `SMTP_PORT` defaults to `587`.
|
||||||
`SMTP_PASSWORD`, and `SMTP_FROM`. Port 465 normally uses
|
Set `SMTP_SECURE=true` for implicit TLS, normally on port 465; the default
|
||||||
`SMTP_SECURE=true`; port 587 normally uses `false` and upgrades with STARTTLS.
|
`false` uses STARTTLS. If the SMTP server requires authentication, set both
|
||||||
Email identities become usable only after the account owner follows a
|
`SMTP_USER` and `SMTP_PASSWORD`. An account owner activates an email identity by
|
||||||
15-minute, single-use verification link.
|
following a 15-minute, single-use verification link.
|
||||||
|
|
||||||
## Validate
|
## Validate
|
||||||
|
|
||||||
@@ -83,46 +98,6 @@ npm run check
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
|
||||||
|
|
||||||
## Creating a project
|
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# create a new project
|
|
||||||
npx sv create my-app
|
|
||||||
```
|
|
||||||
|
|
||||||
To recreate this project with the same configuration:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# recreate this project
|
|
||||||
npx sv@0.16.5 create --template minimal --types ts --install npm .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Developing
|
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
|
||||||
npm run dev -- --open
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
To create a production version of your app:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
|
||||||
The shared visual language and dashboard conventions are documented in
|
The shared visual language and dashboard conventions are documented in
|
||||||
[docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md).
|
[docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md).
|
||||||
|
|
||||||
|
|||||||
+1020
-67
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
bundle_path=${1:-}
|
||||||
|
deployment_directory=${2:-/opt/shd-build-production}
|
||||||
|
if [ -z "$bundle_path" ] || [ ! -f "$bundle_path" ]; then
|
||||||
|
echo "Usage: bootstrap-production.sh <bootstrap-bundle.json> [deployment-directory]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
bundle_path=$(readlink -f "$bundle_path")
|
||||||
|
|
||||||
|
compose_file="$deployment_directory/compose.production.yml"
|
||||||
|
deploy_environment="$deployment_directory/.env.deploy"
|
||||||
|
volume_bundle_path=/app/data/.production-bootstrap-import.json
|
||||||
|
remove_volume_bundle=false
|
||||||
|
compose() {
|
||||||
|
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
|
||||||
|
}
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
set +e
|
||||||
|
if [ "$remove_volume_bundle" = true ]; then
|
||||||
|
compose run --rm --no-deps --entrypoint rm --user root app -f "$volume_bundle_path"
|
||||||
|
fi
|
||||||
|
if ! compose up -d --no-build app scheduler; then status=1; fi
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
echo "Stopping production before the one-time database bootstrap."
|
||||||
|
compose stop scheduler app
|
||||||
|
compose create app >/dev/null
|
||||||
|
app_container=$(compose ps --all --quiet app)
|
||||||
|
if [ -z "$app_container" ]; then
|
||||||
|
echo "The stopped production app container could not be found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker cp "$bundle_path" "$app_container:$volume_bundle_path"
|
||||||
|
remove_volume_bundle=true
|
||||||
|
compose run --rm --no-deps --entrypoint sh --user root app -c \
|
||||||
|
'chown shd-runtime:node "$1" && chmod 600 "$1"' sh "$volume_bundle_path"
|
||||||
|
compose run --rm --no-deps \
|
||||||
|
-e ALLOW_PRODUCTION_BOOTSTRAP=true \
|
||||||
|
app node scripts/environment-bootstrap-import.mjs \
|
||||||
|
"$volume_bundle_path" --target production --confirm-empty-target-replace
|
||||||
|
|
||||||
|
echo "Production bootstrap completed."
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
bundle_path=${1:-}
|
||||||
|
deployment_directory=${2:-/opt/shd-build-stage}
|
||||||
|
if [ -z "$bundle_path" ] || [ ! -f "$bundle_path" ]; then
|
||||||
|
echo "Usage: bootstrap-staging.sh <bootstrap-bundle.json> [deployment-directory]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
bundle_path=$(readlink -f "$bundle_path")
|
||||||
|
|
||||||
|
compose_file="$deployment_directory/compose.staging.yml"
|
||||||
|
deploy_environment="$deployment_directory/.env.deploy"
|
||||||
|
volume_bundle_path=/app/data/.staging-bootstrap-import.json
|
||||||
|
remove_volume_bundle=false
|
||||||
|
compose() {
|
||||||
|
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
|
||||||
|
}
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
set +e
|
||||||
|
if [ "$remove_volume_bundle" = true ]; then
|
||||||
|
compose run --rm --no-deps --entrypoint rm --user root app -f "$volume_bundle_path"
|
||||||
|
fi
|
||||||
|
if ! compose up -d --no-build app scheduler; then status=1; fi
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
echo "Stopping staging before the one-time database bootstrap."
|
||||||
|
compose stop scheduler app
|
||||||
|
compose create app >/dev/null
|
||||||
|
app_container=$(compose ps --all --quiet app)
|
||||||
|
if [ -z "$app_container" ]; then
|
||||||
|
echo "The stopped staging app container could not be found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker cp "$bundle_path" "$app_container:$volume_bundle_path"
|
||||||
|
remove_volume_bundle=true
|
||||||
|
compose run --rm --no-deps --entrypoint sh --user root app -c \
|
||||||
|
'chown shd-runtime:node "$1" && chmod 600 "$1"' sh "$volume_bundle_path"
|
||||||
|
compose run --rm --no-deps \
|
||||||
|
-e ALLOW_STAGING_BOOTSTRAP=true \
|
||||||
|
app node scripts/stage-bootstrap-import.mjs \
|
||||||
|
"$volume_bundle_path" --confirm-empty-staging-replace
|
||||||
|
|
||||||
|
echo "Staging bootstrap completed."
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: shd-build-production
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ${SHD_IMAGE:-shd-build-production:local}
|
||||||
|
restart: always
|
||||||
|
init: true
|
||||||
|
environment:
|
||||||
|
INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
|
||||||
|
INFISICAL_ENVIRONMENT: prod
|
||||||
|
INFISICAL_RUNTIME_SCOPE: app
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: /run/secrets/infisical_client_id
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: /run/secrets/infisical_client_secret
|
||||||
|
SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0}
|
||||||
|
SHD_COMMIT_SHA: ${SHD_COMMIT_SHA:-}
|
||||||
|
ports:
|
||||||
|
- '0.0.0.0:${SHD_PRODUCTION_PORT:-3102}:3000'
|
||||||
|
volumes:
|
||||||
|
- shd_production_data:/app/data
|
||||||
|
secrets:
|
||||||
|
- source: infisical_app_client_id
|
||||||
|
target: infisical_client_id
|
||||||
|
uid: '0'
|
||||||
|
gid: '0'
|
||||||
|
mode: 0400
|
||||||
|
- source: infisical_app_client_secret
|
||||||
|
target: infisical_client_secret
|
||||||
|
uid: '0'
|
||||||
|
gid: '0'
|
||||||
|
mode: 0400
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- node
|
||||||
|
- -e
|
||||||
|
- fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 8
|
||||||
|
start_period: 30s
|
||||||
|
stop_grace_period: 30s
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
image: ${SHD_IMAGE:-shd-build-production:local}
|
||||||
|
restart: always
|
||||||
|
init: true
|
||||||
|
command:
|
||||||
|
- scheduler
|
||||||
|
environment:
|
||||||
|
INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
|
||||||
|
INFISICAL_ENVIRONMENT: prod
|
||||||
|
INFISICAL_RUNTIME_SCOPE: scheduler
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: /run/secrets/infisical_client_id
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: /run/secrets/infisical_client_secret
|
||||||
|
secrets:
|
||||||
|
- source: infisical_scheduler_client_id
|
||||||
|
target: infisical_client_id
|
||||||
|
uid: '0'
|
||||||
|
gid: '0'
|
||||||
|
mode: 0400
|
||||||
|
- source: infisical_scheduler_client_secret
|
||||||
|
target: infisical_client_secret
|
||||||
|
uid: '0'
|
||||||
|
gid: '0'
|
||||||
|
mode: 0400
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
stop_grace_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
shd_production_data:
|
||||||
|
name: shd-build-production-data
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
infisical_app_client_id:
|
||||||
|
file: .infisical.app.client-id
|
||||||
|
infisical_app_client_secret:
|
||||||
|
file: .infisical.app.client-secret
|
||||||
|
infisical_scheduler_client_id:
|
||||||
|
file: .infisical.scheduler.client-id
|
||||||
|
infisical_scheduler_client_secret:
|
||||||
|
file: .infisical.scheduler.client-secret
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: shd-build-stage
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ${SHD_IMAGE:-shd-build-stage:local}
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
environment:
|
||||||
|
INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
|
||||||
|
INFISICAL_ENVIRONMENT: staging
|
||||||
|
INFISICAL_RUNTIME_SCOPE: app
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: /run/secrets/infisical_client_id
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: /run/secrets/infisical_client_secret
|
||||||
|
SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0}
|
||||||
|
SHD_COMMIT_SHA: ${SHD_COMMIT_SHA:-}
|
||||||
|
ports:
|
||||||
|
- '0.0.0.0:${SHD_STAGE_PORT:-5569}:3000'
|
||||||
|
volumes:
|
||||||
|
- shd_stage_data:/app/data
|
||||||
|
secrets:
|
||||||
|
- source: infisical_app_client_id
|
||||||
|
target: infisical_client_id
|
||||||
|
uid: '0'
|
||||||
|
gid: '0'
|
||||||
|
mode: 0400
|
||||||
|
- source: infisical_app_client_secret
|
||||||
|
target: infisical_client_secret
|
||||||
|
uid: '0'
|
||||||
|
gid: '0'
|
||||||
|
mode: 0400
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- node
|
||||||
|
- -e
|
||||||
|
- fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 8
|
||||||
|
start_period: 30s
|
||||||
|
stop_grace_period: 30s
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
image: ${SHD_IMAGE:-shd-build-stage:local}
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
command:
|
||||||
|
- scheduler
|
||||||
|
environment:
|
||||||
|
INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
|
||||||
|
INFISICAL_ENVIRONMENT: staging
|
||||||
|
INFISICAL_RUNTIME_SCOPE: scheduler
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: /run/secrets/infisical_client_id
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: /run/secrets/infisical_client_secret
|
||||||
|
secrets:
|
||||||
|
- source: infisical_scheduler_client_id
|
||||||
|
target: infisical_client_id
|
||||||
|
uid: '0'
|
||||||
|
gid: '0'
|
||||||
|
mode: 0400
|
||||||
|
- source: infisical_scheduler_client_secret
|
||||||
|
target: infisical_client_secret
|
||||||
|
uid: '0'
|
||||||
|
gid: '0'
|
||||||
|
mode: 0400
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
stop_grace_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
shd_stage_data:
|
||||||
|
name: shd-build-stage-data
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
infisical_app_client_id:
|
||||||
|
file: .infisical.app.client-id
|
||||||
|
infisical_app_client_secret:
|
||||||
|
file: .infisical.app.client-secret
|
||||||
|
infisical_scheduler_client_id:
|
||||||
|
file: .infisical.scheduler.client-id
|
||||||
|
infisical_scheduler_client_secret:
|
||||||
|
file: .infisical.scheduler.client-secret
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
project_id=${INFISICAL_PROJECT_ID:?INFISICAL_PROJECT_ID is required}
|
||||||
|
environment=${INFISICAL_ENVIRONMENT:?INFISICAL_ENVIRONMENT is required}
|
||||||
|
runtime_scope=${INFISICAL_RUNTIME_SCOPE:?INFISICAL_RUNTIME_SCOPE is required}
|
||||||
|
client_id_file=${INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE:?INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE is required}
|
||||||
|
client_secret_file=${INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE:?INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE is required}
|
||||||
|
build_number=${SHD_BUILD_NUMBER:-0}
|
||||||
|
commit_sha=${SHD_COMMIT_SHA:-}
|
||||||
|
secret_file=
|
||||||
|
shared_secret_file=
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "$secret_file" ]; then rm -f "$secret_file"; fi
|
||||||
|
if [ -n "$shared_secret_file" ]; then rm -f "$shared_secret_file"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
if [ ! -r "$client_id_file" ] || [ ! -r "$client_secret_file" ]; then
|
||||||
|
echo "Infisical Universal Auth credential files are not readable." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
runtime_uid=$(id -u shd-runtime)
|
||||||
|
for credential_file in "$client_id_file" "$client_secret_file"; do
|
||||||
|
credential_uid=$(stat -c '%u' "$credential_file")
|
||||||
|
credential_mode=$(stat -c '%a' "$credential_file")
|
||||||
|
if [ "$credential_uid" -eq "$runtime_uid" ] || [ $((credential_mode % 100)) -ne 0 ]; then
|
||||||
|
echo "Infisical credential files must not be readable by the application runtime user." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$runtime_scope" = app ] && ! { [ "${1:-}" = node ] && [ "${2:-}" = scripts/validate-runtime-environment.mjs ]; }; then
|
||||||
|
chgrp -R node /app/data
|
||||||
|
chmod -R g+rwX /app/data
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
client_id=$(cat "$client_id_file")
|
||||||
|
client_secret=$(cat "$client_secret_file")
|
||||||
|
if [ -z "$client_id" ] || [ -z "$client_secret" ]; then
|
||||||
|
echo "Infisical Universal Auth credential files must not be empty." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
unset INFISICAL_TOKEN INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN TOKEN
|
||||||
|
access_token=$(
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID="$client_id" \
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET="$client_secret" \
|
||||||
|
infisical login --method=universal-auth --silent --plain
|
||||||
|
)
|
||||||
|
unset client_id client_secret
|
||||||
|
|
||||||
|
if [ -z "$access_token" ]; then
|
||||||
|
echo "Infisical Universal Auth returned an empty access token." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
secret_file=$(mktemp /tmp/shd-infisical-app.XXXXXX)
|
||||||
|
|
||||||
|
case "$runtime_scope" in
|
||||||
|
app)
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN="$access_token" infisical export \
|
||||||
|
--projectId="$project_id" \
|
||||||
|
--env="$environment" \
|
||||||
|
--path=/app \
|
||||||
|
--format=json \
|
||||||
|
--output-file="$secret_file" \
|
||||||
|
--silent
|
||||||
|
shared_secret_file=$(mktemp /tmp/shd-infisical-shared.XXXXXX)
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN="$access_token" infisical export \
|
||||||
|
--projectId="$project_id" \
|
||||||
|
--env="$environment" \
|
||||||
|
--path=/shared/scheduler \
|
||||||
|
--format=json \
|
||||||
|
--output-file="$shared_secret_file" \
|
||||||
|
--silent
|
||||||
|
;;
|
||||||
|
scheduler)
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN="$access_token" infisical export \
|
||||||
|
--projectId="$project_id" \
|
||||||
|
--env="$environment" \
|
||||||
|
--path=/shared/scheduler \
|
||||||
|
--format=json \
|
||||||
|
--output-file="$secret_file" \
|
||||||
|
--silent
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported INFISICAL_RUNTIME_SCOPE: $runtime_scope" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
unset access_token \
|
||||||
|
INFISICAL_TOKEN \
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN \
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID \
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET
|
||||||
|
|
||||||
|
if [ "$runtime_scope" = app ]; then
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
chown shd-runtime:node "$secret_file" "$shared_secret_file"
|
||||||
|
exec gosu shd-runtime node scripts/runtime-launcher.mjs app "$environment" "$build_number" "$commit_sha" "$secret_file" "$shared_secret_file" -- "$@"
|
||||||
|
fi
|
||||||
|
exec node scripts/runtime-launcher.mjs app "$environment" "$build_number" "$commit_sha" "$secret_file" "$shared_secret_file" -- "$@"
|
||||||
|
fi
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
chown shd-runtime:node "$secret_file"
|
||||||
|
exec gosu shd-runtime node scripts/runtime-launcher.mjs scheduler "$environment" "$build_number" "$commit_sha" "$secret_file" -- "$@"
|
||||||
|
fi
|
||||||
|
exec node scripts/runtime-launcher.mjs scheduler "$environment" "$build_number" "$commit_sha" "$secret_file" -- "$@"
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
deployment_directory=${1:-/opt/shd-build-production}
|
||||||
|
source_directory=${2:-}
|
||||||
|
candidate_image=${3:-}
|
||||||
|
candidate_directory=${4:-}
|
||||||
|
if [ -z "$source_directory" ] || [ ! -f "$source_directory/Dockerfile" ] || [ -z "$candidate_image" ] || [ ! -f "$candidate_directory/compose.production.yml" ] || [ ! -f "$candidate_directory/.env.deploy" ]; then
|
||||||
|
echo "Usage: deploy-production.sh <deployment-directory> <source-directory> <candidate-image> <candidate-directory>" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
case "$candidate_directory" in
|
||||||
|
"$deployment_directory"/candidate-*) ;;
|
||||||
|
*) echo "Candidate directory must be a candidate-* child of the deployment directory." >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
candidate_compose_file="$candidate_directory/compose.production.yml"
|
||||||
|
candidate_environment="$candidate_directory/.env.deploy"
|
||||||
|
canonical_compose_file="$deployment_directory/compose.production.yml"
|
||||||
|
canonical_environment="$deployment_directory/.env.deploy"
|
||||||
|
previous_directory="$deployment_directory/.previous-release"
|
||||||
|
healthy_marker="$deployment_directory/.deployment-healthy"
|
||||||
|
release_files="compose.production.yml .env.deploy .infisical.app.client-id .infisical.app.client-secret .infisical.scheduler.client-id .infisical.scheduler.client-secret deploy-production.sh bootstrap-production.sh sync-production-isac-to-staging.sh"
|
||||||
|
rollback_required=false
|
||||||
|
candidate_promoted=false
|
||||||
|
had_previous_release=false
|
||||||
|
|
||||||
|
candidate_compose() {
|
||||||
|
docker compose --env-file "$candidate_environment" -f "$candidate_compose_file" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
canonical_compose() {
|
||||||
|
docker compose --env-file "$canonical_environment" -f "$canonical_compose_file" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
release_is_healthy() {
|
||||||
|
health_environment=$1
|
||||||
|
health_compose_file=$2
|
||||||
|
health_attempt=0
|
||||||
|
while [ "$health_attempt" -lt 30 ]; do
|
||||||
|
health_app_container=$(docker compose --env-file "$health_environment" -f "$health_compose_file" ps -q app)
|
||||||
|
health_scheduler_container=$(docker compose --env-file "$health_environment" -f "$health_compose_file" ps -q scheduler)
|
||||||
|
health_app_status=missing
|
||||||
|
health_scheduler_status=missing
|
||||||
|
if [ -n "$health_app_container" ]; then
|
||||||
|
health_app_status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$health_app_container" 2>/dev/null || echo missing)
|
||||||
|
fi
|
||||||
|
if [ -n "$health_scheduler_container" ]; then
|
||||||
|
health_scheduler_status=$(docker inspect --format '{{.State.Status}}' "$health_scheduler_container" 2>/dev/null || echo missing)
|
||||||
|
fi
|
||||||
|
if [ "$health_app_status" = healthy ] && [ "$health_scheduler_status" = running ]; then return 0; fi
|
||||||
|
if [ "$health_app_status" = unhealthy ] || [ "$health_app_status" = exited ] || [ "$health_app_status" = dead ] || [ "$health_scheduler_status" = exited ] || [ "$health_scheduler_status" = dead ]; then return 1; fi
|
||||||
|
health_attempt=$((health_attempt + 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_release() {
|
||||||
|
copy_source=$1
|
||||||
|
copy_target=$2
|
||||||
|
copy_status=0
|
||||||
|
for release_file in $release_files; do
|
||||||
|
if [ -f "$copy_source/$release_file" ]; then
|
||||||
|
copy_temporary=$(mktemp "$copy_target/.release.XXXXXX") || return 1
|
||||||
|
if cp -p "$copy_source/$release_file" "$copy_temporary"; then
|
||||||
|
mv "$copy_temporary" "$copy_target/$release_file" || copy_status=1
|
||||||
|
else
|
||||||
|
rm -f "$copy_temporary"
|
||||||
|
copy_status=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
rm -f "$copy_target/$release_file" || copy_status=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return "$copy_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_candidate() {
|
||||||
|
case "$candidate_directory" in
|
||||||
|
"$deployment_directory"/candidate-*) rm -rf "$candidate_directory" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback() {
|
||||||
|
rollback_exit_status=$?
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
set +e
|
||||||
|
if [ "$rollback_required" = true ]; then
|
||||||
|
echo "Restoring the previous healthy production release." >&2
|
||||||
|
restore_status=0
|
||||||
|
candidate_compose stop scheduler app >/dev/null 2>&1 || restore_status=1
|
||||||
|
if [ "$candidate_promoted" = true ]; then copy_release "$previous_directory" "$deployment_directory" || restore_status=1; fi
|
||||||
|
if [ "$had_previous_release" = true ]; then
|
||||||
|
canonical_compose up -d --no-build app scheduler || restore_status=1
|
||||||
|
if [ "$restore_status" -eq 0 ] && ! release_is_healthy "$canonical_environment" "$canonical_compose_file"; then restore_status=1; fi
|
||||||
|
else
|
||||||
|
echo "No previous production release is available." >&2
|
||||||
|
restore_status=1
|
||||||
|
fi
|
||||||
|
if [ "$restore_status" -ne 0 ]; then
|
||||||
|
echo "The previous production release could not be restored." >&2
|
||||||
|
if [ "$rollback_exit_status" -eq 0 ]; then rollback_exit_status=1; fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
cleanup_candidate
|
||||||
|
exit "$rollback_exit_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap rollback EXIT
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
echo "Building the candidate image while production remains online."
|
||||||
|
docker build --label org.opencontainers.image.title=shd-build -t "$candidate_image" "$source_directory"
|
||||||
|
|
||||||
|
echo "Validating Infisical access and the candidate runtime configuration."
|
||||||
|
candidate_compose run --rm --no-deps app node scripts/validate-runtime-environment.mjs app
|
||||||
|
candidate_compose run --rm --no-deps scheduler node scripts/validate-runtime-environment.mjs scheduler
|
||||||
|
|
||||||
|
rm -rf "$previous_directory"
|
||||||
|
mkdir -p "$previous_directory"
|
||||||
|
if [ -f "$canonical_compose_file" ] && [ -f "$canonical_environment" ]; then
|
||||||
|
had_previous_release=true
|
||||||
|
copy_release "$deployment_directory" "$previous_directory"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Stopping only SHD production processes before accessing the embedded database."
|
||||||
|
rollback_required=true
|
||||||
|
candidate_compose stop scheduler app
|
||||||
|
|
||||||
|
echo "Creating a timestamped production backup when the database exists."
|
||||||
|
candidate_compose run --rm --no-deps app sh -c \
|
||||||
|
'if [ -f "$TDR_DATABASE_PATH" ]; then node scripts/db-backup.mjs; else echo "No production database exists yet; skipping backup."; fi'
|
||||||
|
|
||||||
|
echo "Applying additive schema migrations."
|
||||||
|
candidate_compose run --rm --no-deps app node scripts/db-migrate.mjs
|
||||||
|
|
||||||
|
candidate_promoted=true
|
||||||
|
copy_release "$candidate_directory" "$deployment_directory"
|
||||||
|
|
||||||
|
echo "Starting production."
|
||||||
|
canonical_compose up -d --no-build --remove-orphans app scheduler
|
||||||
|
|
||||||
|
if release_is_healthy "$canonical_environment" "$canonical_compose_file"; then
|
||||||
|
echo "SHD production deployment is healthy."
|
||||||
|
rollback_required=false
|
||||||
|
umask 077
|
||||||
|
: > "$healthy_marker"
|
||||||
|
cleanup_candidate
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "SHD production failed its health check."
|
||||||
|
canonical_compose logs --tail=150 app scheduler
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
deployment_directory=${1:-/opt/shd-build-stage}
|
||||||
|
source_directory=${2:-}
|
||||||
|
candidate_image=${3:-}
|
||||||
|
candidate_directory=${4:-}
|
||||||
|
if [ -z "$source_directory" ] || [ ! -f "$source_directory/Dockerfile" ] || [ -z "$candidate_image" ] || [ ! -f "$candidate_directory/compose.staging.yml" ] || [ ! -f "$candidate_directory/.env.deploy" ]; then
|
||||||
|
echo "Usage: deploy-staging.sh <deployment-directory> <source-directory> <candidate-image> <candidate-directory>" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
case "$candidate_directory" in
|
||||||
|
"$deployment_directory"/candidate-*) ;;
|
||||||
|
*) echo "Candidate directory must be a candidate-* child of the deployment directory." >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
candidate_compose_file="$candidate_directory/compose.staging.yml"
|
||||||
|
candidate_environment="$candidate_directory/.env.deploy"
|
||||||
|
canonical_compose_file="$deployment_directory/compose.staging.yml"
|
||||||
|
canonical_environment="$deployment_directory/.env.deploy"
|
||||||
|
previous_directory="$deployment_directory/.previous-release"
|
||||||
|
healthy_marker="$deployment_directory/.deployment-healthy"
|
||||||
|
release_files="compose.staging.yml .env.deploy .infisical.app.client-id .infisical.app.client-secret .infisical.scheduler.client-id .infisical.scheduler.client-secret deploy-staging.sh bootstrap-staging.sh"
|
||||||
|
rollback_required=false
|
||||||
|
candidate_promoted=false
|
||||||
|
had_previous_release=false
|
||||||
|
|
||||||
|
candidate_compose() {
|
||||||
|
docker compose --env-file "$candidate_environment" -f "$candidate_compose_file" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
canonical_compose() {
|
||||||
|
docker compose --env-file "$canonical_environment" -f "$canonical_compose_file" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
release_is_healthy() {
|
||||||
|
health_environment=$1
|
||||||
|
health_compose_file=$2
|
||||||
|
health_attempt=0
|
||||||
|
while [ "$health_attempt" -lt 30 ]; do
|
||||||
|
health_app_container=$(docker compose --env-file "$health_environment" -f "$health_compose_file" ps -q app)
|
||||||
|
health_scheduler_container=$(docker compose --env-file "$health_environment" -f "$health_compose_file" ps -q scheduler)
|
||||||
|
health_app_status=missing
|
||||||
|
health_scheduler_status=missing
|
||||||
|
if [ -n "$health_app_container" ]; then
|
||||||
|
health_app_status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$health_app_container" 2>/dev/null || echo missing)
|
||||||
|
fi
|
||||||
|
if [ -n "$health_scheduler_container" ]; then
|
||||||
|
health_scheduler_status=$(docker inspect --format '{{.State.Status}}' "$health_scheduler_container" 2>/dev/null || echo missing)
|
||||||
|
fi
|
||||||
|
if [ "$health_app_status" = healthy ] && [ "$health_scheduler_status" = running ]; then return 0; fi
|
||||||
|
if [ "$health_app_status" = unhealthy ] || [ "$health_app_status" = exited ] || [ "$health_app_status" = dead ] || [ "$health_scheduler_status" = exited ] || [ "$health_scheduler_status" = dead ]; then return 1; fi
|
||||||
|
health_attempt=$((health_attempt + 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_release() {
|
||||||
|
copy_source=$1
|
||||||
|
copy_target=$2
|
||||||
|
copy_status=0
|
||||||
|
for release_file in $release_files; do
|
||||||
|
if [ -f "$copy_source/$release_file" ]; then
|
||||||
|
copy_temporary=$(mktemp "$copy_target/.release.XXXXXX") || return 1
|
||||||
|
if cp -p "$copy_source/$release_file" "$copy_temporary"; then
|
||||||
|
mv "$copy_temporary" "$copy_target/$release_file" || copy_status=1
|
||||||
|
else
|
||||||
|
rm -f "$copy_temporary"
|
||||||
|
copy_status=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
rm -f "$copy_target/$release_file" || copy_status=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return "$copy_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_candidate() {
|
||||||
|
case "$candidate_directory" in
|
||||||
|
"$deployment_directory"/candidate-*) rm -rf "$candidate_directory" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback() {
|
||||||
|
rollback_exit_status=$?
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
set +e
|
||||||
|
if [ "$rollback_required" = true ]; then
|
||||||
|
echo "Restoring the previous healthy staging release." >&2
|
||||||
|
restore_status=0
|
||||||
|
candidate_compose stop scheduler app >/dev/null 2>&1 || restore_status=1
|
||||||
|
if [ "$candidate_promoted" = true ]; then copy_release "$previous_directory" "$deployment_directory" || restore_status=1; fi
|
||||||
|
if [ "$had_previous_release" = true ]; then
|
||||||
|
canonical_compose up -d --no-build app scheduler || restore_status=1
|
||||||
|
if [ "$restore_status" -eq 0 ] && ! release_is_healthy "$canonical_environment" "$canonical_compose_file"; then restore_status=1; fi
|
||||||
|
else
|
||||||
|
echo "No previous staging release is available." >&2
|
||||||
|
restore_status=1
|
||||||
|
fi
|
||||||
|
if [ "$restore_status" -ne 0 ]; then
|
||||||
|
echo "The previous staging release could not be restored." >&2
|
||||||
|
if [ "$rollback_exit_status" -eq 0 ]; then rollback_exit_status=1; fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
cleanup_candidate
|
||||||
|
exit "$rollback_exit_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap rollback EXIT
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
echo "Building the candidate image while the current staging service remains online."
|
||||||
|
docker build --label org.opencontainers.image.title=shd-build -t "$candidate_image" "$source_directory"
|
||||||
|
|
||||||
|
echo "Validating Infisical access and the candidate runtime configuration."
|
||||||
|
candidate_compose run --rm --no-deps app node scripts/validate-runtime-environment.mjs app
|
||||||
|
candidate_compose run --rm --no-deps scheduler node scripts/validate-runtime-environment.mjs scheduler
|
||||||
|
|
||||||
|
rm -rf "$previous_directory"
|
||||||
|
mkdir -p "$previous_directory"
|
||||||
|
if [ -f "$canonical_compose_file" ] && [ -f "$canonical_environment" ]; then
|
||||||
|
had_previous_release=true
|
||||||
|
copy_release "$deployment_directory" "$previous_directory"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Stopping SHD staging processes before accessing the embedded database."
|
||||||
|
rollback_required=true
|
||||||
|
candidate_compose stop scheduler app
|
||||||
|
|
||||||
|
echo "Creating a timestamped database backup when a staging database exists."
|
||||||
|
candidate_compose run --rm --no-deps app sh -c \
|
||||||
|
'if [ -f "$TDR_DATABASE_PATH" ]; then node scripts/db-backup.mjs; else echo "No staging database exists yet; skipping backup."; fi'
|
||||||
|
|
||||||
|
echo "Applying additive schema migrations."
|
||||||
|
candidate_compose run --rm --no-deps app node scripts/db-migrate.mjs
|
||||||
|
|
||||||
|
candidate_promoted=true
|
||||||
|
copy_release "$candidate_directory" "$deployment_directory"
|
||||||
|
|
||||||
|
echo "Starting the application and notification scheduler."
|
||||||
|
canonical_compose up -d --no-build --remove-orphans app scheduler
|
||||||
|
|
||||||
|
if release_is_healthy "$canonical_environment" "$canonical_compose_file"; then
|
||||||
|
echo "SHD staging deployment is healthy."
|
||||||
|
rollback_required=false
|
||||||
|
umask 077
|
||||||
|
: > "$healthy_marker"
|
||||||
|
cleanup_candidate
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "SHD staging failed its health check."
|
||||||
|
canonical_compose logs --tail=150 app scheduler
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import {
|
||||||
|
copyFile,
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
writeFile,
|
||||||
|
} from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(import.meta.dirname, '..');
|
||||||
|
const schedulerToken = 'scheduler-token-with-at-least-32-characters';
|
||||||
|
|
||||||
|
const infisicalStub = `#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
case "\${1:-}" in
|
||||||
|
login)
|
||||||
|
printf '%s\n' "$@" > "$STUB_LOGIN_LOG"
|
||||||
|
if [ "\${INFISICAL_UNIVERSAL_AUTH_CLIENT_ID:-}" != "$STUB_EXPECTED_CLIENT_ID" ] || \
|
||||||
|
[ "\${INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET:-}" != "$STUB_EXPECTED_CLIENT_SECRET" ]; then
|
||||||
|
printf '%s\n' 'credentials=incorrect' >> "$STUB_LOGIN_LOG"
|
||||||
|
exit 26
|
||||||
|
fi
|
||||||
|
printf '%s\n' 'credentials=ok' >> "$STUB_LOGIN_LOG"
|
||||||
|
case "\${STUB_LOGIN_RESULT:-success}" in
|
||||||
|
fail) exit 23 ;;
|
||||||
|
empty) exit 0 ;;
|
||||||
|
*) printf '%s\n' stub-access-token ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
export)
|
||||||
|
[ "\${INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN:-}" = stub-access-token ] || exit 27
|
||||||
|
secret_path=
|
||||||
|
output_file=
|
||||||
|
for argument in "$@"; do
|
||||||
|
case "$argument" in
|
||||||
|
--path=*) secret_path=\${argument#--path=} ;;
|
||||||
|
--output-file=*) output_file=\${argument#--output-file=} ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -n "$secret_path" ] && [ -n "$output_file" ] || exit 28
|
||||||
|
printf '%s|%s\n' "$secret_path" "$output_file" >> "$STUB_EXPORT_LOG"
|
||||||
|
case "$secret_path" in
|
||||||
|
/app) cp "$STUB_APP_EXPORT" "$output_file" ;;
|
||||||
|
/shared/scheduler) cp "$STUB_SHARED_EXPORT" "$output_file" ;;
|
||||||
|
*) exit 29 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*) exit 25 ;;
|
||||||
|
esac
|
||||||
|
`;
|
||||||
|
|
||||||
|
const environmentCapture = `#!/bin/sh
|
||||||
|
env > "$CAPTURE_ENV_PATH"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const gosuStub = `#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
[ "\${1:-}" = shd-runtime ] || exit 30
|
||||||
|
printf '%s\n' 'target=shd-runtime' > "$STUB_GOSU_LOG"
|
||||||
|
shift
|
||||||
|
exec "$@"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const idStub = `#!/bin/sh
|
||||||
|
if [ "\${1:-}" = -u ] && [ "\${2:-}" = shd-runtime ]; then
|
||||||
|
printf '%s\n' 10001
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exec /usr/bin/id "$@"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ownershipStub = `#!/bin/sh
|
||||||
|
exit 0
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe('container runtime startup', () => {
|
||||||
|
let temporaryRoot: string;
|
||||||
|
let clientIdFile: string;
|
||||||
|
let clientSecretFile: string;
|
||||||
|
let loginLog: string;
|
||||||
|
let exportLog: string;
|
||||||
|
let gosuLog: string;
|
||||||
|
let capturedEnvironment: string;
|
||||||
|
let appExport: string;
|
||||||
|
let sharedExport: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
temporaryRoot = await mkdtemp(join(tmpdir(), 'shd-runtime-start-'));
|
||||||
|
const deployDirectory = join(temporaryRoot, 'deploy');
|
||||||
|
const scriptsDirectory = join(temporaryRoot, 'scripts');
|
||||||
|
await mkdir(deployDirectory);
|
||||||
|
await mkdir(scriptsDirectory);
|
||||||
|
await Promise.all([
|
||||||
|
copyFile(
|
||||||
|
join(repositoryRoot, 'deploy/container-entrypoint.sh'),
|
||||||
|
join(deployDirectory, 'container-entrypoint.sh'),
|
||||||
|
),
|
||||||
|
copyFile(
|
||||||
|
join(repositoryRoot, 'scripts/runtime-launcher.mjs'),
|
||||||
|
join(scriptsDirectory, 'runtime-launcher.mjs'),
|
||||||
|
),
|
||||||
|
copyFile(
|
||||||
|
join(repositoryRoot, 'scripts/runtime-environment.js'),
|
||||||
|
join(scriptsDirectory, 'runtime-environment.js'),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
clientIdFile = join(temporaryRoot, 'client-id');
|
||||||
|
clientSecretFile = join(temporaryRoot, 'client-secret');
|
||||||
|
loginLog = join(temporaryRoot, 'login.log');
|
||||||
|
exportLog = join(temporaryRoot, 'export.log');
|
||||||
|
gosuLog = join(temporaryRoot, 'gosu.log');
|
||||||
|
capturedEnvironment = join(temporaryRoot, 'child.env');
|
||||||
|
appExport = join(temporaryRoot, 'app-export.json');
|
||||||
|
sharedExport = join(temporaryRoot, 'shared-export.json');
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(clientIdFile, 'universal-client-id\n', { mode: 0o600 }),
|
||||||
|
writeFile(clientSecretFile, 'universal-client-secret\n', { mode: 0o600 }),
|
||||||
|
writeFile(join(temporaryRoot, 'infisical'), infisicalStub, {
|
||||||
|
mode: 0o755,
|
||||||
|
}),
|
||||||
|
writeFile(
|
||||||
|
join(temporaryRoot, 'capture-environment'),
|
||||||
|
environmentCapture,
|
||||||
|
{
|
||||||
|
mode: 0o755,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeFile(join(temporaryRoot, 'gosu'), gosuStub, { mode: 0o755 }),
|
||||||
|
writeFile(join(temporaryRoot, 'id'), idStub, { mode: 0o755 }),
|
||||||
|
writeFile(join(temporaryRoot, 'chgrp'), ownershipStub, { mode: 0o755 }),
|
||||||
|
writeFile(join(temporaryRoot, 'chmod'), ownershipStub, { mode: 0o755 }),
|
||||||
|
writeFile(join(temporaryRoot, 'chown'), ownershipStub, { mode: 0o755 }),
|
||||||
|
writeExport(appExport, [
|
||||||
|
{ key: 'APP_ORIGIN', value: 'https://exported.invalid' },
|
||||||
|
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
|
||||||
|
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
|
||||||
|
{
|
||||||
|
key: 'MFA_TOTP_ENCRYPTION_KEYS',
|
||||||
|
value: 'totp-encryption-keyring',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
writeExport(sharedExport, [
|
||||||
|
{ key: 'SCHEDULER_TOKEN', value: schedulerToken },
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(temporaryRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function runtimeEnvironment(
|
||||||
|
scope: 'app' | 'scheduler',
|
||||||
|
): Record<string, string> {
|
||||||
|
return {
|
||||||
|
PATH: `${temporaryRoot}:${process.env.PATH ?? ''}`,
|
||||||
|
INFISICAL_PROJECT_ID: 'project-id',
|
||||||
|
INFISICAL_ENVIRONMENT: 'staging',
|
||||||
|
INFISICAL_RUNTIME_SCOPE: scope,
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: clientIdFile,
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: clientSecretFile,
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID: 'bootstrap-client-id',
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET: 'bootstrap-client-secret',
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN: 'bootstrap-access-token',
|
||||||
|
INFISICAL_TOKEN: 'bootstrap-token',
|
||||||
|
TOKEN: 'generic-bootstrap-token',
|
||||||
|
STUB_EXPECTED_CLIENT_ID: 'universal-client-id',
|
||||||
|
STUB_EXPECTED_CLIENT_SECRET: 'universal-client-secret',
|
||||||
|
STUB_LOGIN_LOG: loginLog,
|
||||||
|
STUB_EXPORT_LOG: exportLog,
|
||||||
|
STUB_GOSU_LOG: gosuLog,
|
||||||
|
STUB_APP_EXPORT: appExport,
|
||||||
|
STUB_SHARED_EXPORT: sharedExport,
|
||||||
|
CAPTURE_ENV_PATH: capturedEnvironment,
|
||||||
|
SHD_BUILD_NUMBER: '42',
|
||||||
|
SHD_COMMIT_SHA: 'abc123',
|
||||||
|
APP_ORIGIN: 'https://parent.invalid',
|
||||||
|
SCHEDULER_URL: 'https://parent.invalid/scheduler',
|
||||||
|
BASH_ENV: '',
|
||||||
|
CDPATH: temporaryRoot,
|
||||||
|
ENV: '',
|
||||||
|
IFS: 'hostile-ifs',
|
||||||
|
LD_LIBRARY_PATH: temporaryRoot,
|
||||||
|
LD_PRELOAD: '',
|
||||||
|
NODE_OPTIONS: '--no-warnings',
|
||||||
|
NODE_PATH: temporaryRoot,
|
||||||
|
PYTHONPATH: temporaryRoot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(environment: Record<string, string>) {
|
||||||
|
return spawnSync(
|
||||||
|
'/bin/sh',
|
||||||
|
['./deploy/container-entrypoint.sh', 'capture-environment'],
|
||||||
|
{
|
||||||
|
cwd: temporaryRoot,
|
||||||
|
env: environment,
|
||||||
|
encoding: 'utf8',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('exports app and shared secrets into a scrubbed app child environment', async () => {
|
||||||
|
const result = start(runtimeEnvironment('app'));
|
||||||
|
|
||||||
|
expect(result.status, result.stderr).toBe(0);
|
||||||
|
expect(await exportPaths()).toEqual(['/app', '/shared/scheduler']);
|
||||||
|
expect(await readFile(loginLog, 'utf8')).toBe(
|
||||||
|
'login\n--method=universal-auth\n--silent\n--plain\ncredentials=ok\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const childEnvironment = parseEnvironment(
|
||||||
|
await readFile(capturedEnvironment, 'utf8'),
|
||||||
|
);
|
||||||
|
expect(childEnvironment).toMatchObject({
|
||||||
|
APP_ORIGIN: 'https://stage.shd.build',
|
||||||
|
DISCORD_CLIENT_ID: 'exported-discord-client',
|
||||||
|
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
|
||||||
|
MFA_TOTP_ENCRYPTION_KEYS: 'totp-encryption-keyring',
|
||||||
|
ORIGIN: 'https://stage.shd.build',
|
||||||
|
PASSKEY_RP_ID: 'shd.build',
|
||||||
|
SCHEDULER_TOKEN: schedulerToken,
|
||||||
|
SHD_BUILD_NUMBER: '42',
|
||||||
|
SHD_COMMIT_SHA: 'abc123',
|
||||||
|
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
||||||
|
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
|
||||||
|
TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons',
|
||||||
|
});
|
||||||
|
for (const name of removedEnvironmentNames) {
|
||||||
|
expect(childEnvironment).not.toHaveProperty(name);
|
||||||
|
}
|
||||||
|
await expectSecretFilesRemoved();
|
||||||
|
await expectRootPrivilegeDrop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports only shared secrets into the scheduler child environment', async () => {
|
||||||
|
const result = start(runtimeEnvironment('scheduler'));
|
||||||
|
|
||||||
|
expect(result.status, result.stderr).toBe(0);
|
||||||
|
expect(await exportPaths()).toEqual(['/shared/scheduler']);
|
||||||
|
expect(
|
||||||
|
parseEnvironment(await readFile(capturedEnvironment, 'utf8')),
|
||||||
|
).toMatchObject({
|
||||||
|
SCHEDULER_TOKEN: schedulerToken,
|
||||||
|
SCHEDULER_URL: 'http://app:3000/api/internal/scheduler',
|
||||||
|
});
|
||||||
|
await expectSecretFilesRemoved();
|
||||||
|
await expectRootPrivilegeDrop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed on an unknown secret before launching the child', async () => {
|
||||||
|
await writeExport(appExport, [
|
||||||
|
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
|
||||||
|
{ key: 'UNKNOWN_SECRET', value: 'must-not-reach-child' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = start(runtimeEnvironment('app'));
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain(
|
||||||
|
'Infisical secret UNKNOWN_SECRET is not allowed in this runtime scope.',
|
||||||
|
);
|
||||||
|
expect(existsSync(capturedEnvironment)).toBe(false);
|
||||||
|
await expectSecretFilesRemoved();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when a credential file setting is missing', () => {
|
||||||
|
const environment = runtimeEnvironment('app');
|
||||||
|
delete environment.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE;
|
||||||
|
|
||||||
|
const result = start(environment);
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain(
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE is required',
|
||||||
|
);
|
||||||
|
expect(existsSync(exportLog)).toBe(false);
|
||||||
|
expect(existsSync(capturedEnvironment)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when either credential is empty', async () => {
|
||||||
|
for (const credentialFile of [clientIdFile, clientSecretFile]) {
|
||||||
|
await writeFile(credentialFile, '');
|
||||||
|
|
||||||
|
const result = start(runtimeEnvironment('app'));
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain('credential files must not be empty');
|
||||||
|
expect(existsSync(exportLog)).toBe(false);
|
||||||
|
expect(existsSync(capturedEnvironment)).toBe(false);
|
||||||
|
await writeFile(
|
||||||
|
credentialFile,
|
||||||
|
credentialFile === clientIdFile
|
||||||
|
? 'universal-client-id\n'
|
||||||
|
: 'universal-client-secret\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['fail', 'empty'])(
|
||||||
|
'fails closed when login returns %s',
|
||||||
|
(loginResult) => {
|
||||||
|
const result = start({
|
||||||
|
...runtimeEnvironment('app'),
|
||||||
|
STUB_LOGIN_RESULT: loginResult,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(existsSync(exportLog)).toBe(false);
|
||||||
|
expect(existsSync(capturedEnvironment)).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('fails closed on a malformed export and removes every secret file', async () => {
|
||||||
|
await writeFile(appExport, '{not-json');
|
||||||
|
|
||||||
|
const result = start(runtimeEnvironment('app'));
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain(
|
||||||
|
'Infisical returned an unreadable secret export.',
|
||||||
|
);
|
||||||
|
expect(existsSync(capturedEnvironment)).toBe(false);
|
||||||
|
await expectSecretFilesRemoved();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function exportPaths() {
|
||||||
|
return (await exportRecords()).map(([path]) => path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSecretFilesRemoved() {
|
||||||
|
for (const [, file] of await exportRecords()) {
|
||||||
|
expect(existsSync(file)).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportRecords() {
|
||||||
|
return (await readFile(exportLog, 'utf8'))
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.split('|'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectRootPrivilegeDrop() {
|
||||||
|
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
||||||
|
expect(await readFile(gosuLog, 'utf8')).toBe('target=shd-runtime\n');
|
||||||
|
} else {
|
||||||
|
expect(existsSync(gosuLog)).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const removedEnvironmentNames = [
|
||||||
|
'BASH_ENV',
|
||||||
|
'CDPATH',
|
||||||
|
'ENV',
|
||||||
|
'IFS',
|
||||||
|
'INFISICAL_ENVIRONMENT',
|
||||||
|
'INFISICAL_PROJECT_ID',
|
||||||
|
'INFISICAL_RUNTIME_SCOPE',
|
||||||
|
'INFISICAL_TOKEN',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE',
|
||||||
|
'LD_LIBRARY_PATH',
|
||||||
|
'LD_PRELOAD',
|
||||||
|
'NODE_OPTIONS',
|
||||||
|
'NODE_PATH',
|
||||||
|
'PYTHONPATH',
|
||||||
|
'TOKEN',
|
||||||
|
];
|
||||||
|
|
||||||
|
function writeExport(
|
||||||
|
file: string,
|
||||||
|
entries: Array<{ key: string; value: string }>,
|
||||||
|
) {
|
||||||
|
return writeFile(file, `${JSON.stringify(entries)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvironment(value: string) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
const separator = line.indexOf('=');
|
||||||
|
return [line.slice(0, separator), line.slice(separator + 1)];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
production_directory=${1:-/opt/shd-build-production}
|
||||||
|
staging_directory=${2:-/opt/shd-build-stage}
|
||||||
|
confirmation=${3:-}
|
||||||
|
if [ "$confirmation" != "--confirm-replace-staging-isac-data" ]; then
|
||||||
|
echo "Usage: sync-production-isac-to-staging.sh [production-dir] [staging-dir] --confirm-replace-staging-isac-data" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
production_compose_file="$production_directory/compose.production.yml"
|
||||||
|
production_environment="$production_directory/.env.deploy"
|
||||||
|
staging_compose_file="$staging_directory/compose.staging.yml"
|
||||||
|
staging_environment="$staging_directory/.env.deploy"
|
||||||
|
snapshot_name="isac-production-$(date -u +%Y-%m-%dT%H-%M-%SZ).json"
|
||||||
|
snapshot_path=$(mktemp "/tmp/$snapshot_name.XXXXXX")
|
||||||
|
production_snapshot_path="/app/data/exports/$snapshot_name"
|
||||||
|
staging_snapshot_path="/app/data/$snapshot_name"
|
||||||
|
remove_production_snapshot=false
|
||||||
|
remove_staging_snapshot=false
|
||||||
|
|
||||||
|
production_compose() {
|
||||||
|
docker compose --env-file "$production_environment" -f "$production_compose_file" "$@"
|
||||||
|
}
|
||||||
|
staging_compose() {
|
||||||
|
docker compose --env-file "$staging_environment" -f "$staging_compose_file" "$@"
|
||||||
|
}
|
||||||
|
cleanup() {
|
||||||
|
exit_status=$?
|
||||||
|
trap - EXIT
|
||||||
|
if [ "$remove_production_snapshot" = true ]; then
|
||||||
|
production_compose run --rm --no-deps app rm -f "$production_snapshot_path" || true
|
||||||
|
fi
|
||||||
|
if [ "$remove_staging_snapshot" = true ]; then
|
||||||
|
staging_compose run --rm --no-deps app rm -f "$staging_snapshot_path" || true
|
||||||
|
fi
|
||||||
|
rm -f "$snapshot_path" || true
|
||||||
|
if ! production_compose up -d --no-build app scheduler; then
|
||||||
|
if [ "$exit_status" -eq 0 ]; then exit_status=1; fi
|
||||||
|
fi
|
||||||
|
if ! staging_compose up -d --no-build app scheduler; then
|
||||||
|
if [ "$exit_status" -eq 0 ]; then exit_status=1; fi
|
||||||
|
fi
|
||||||
|
exit "$exit_status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "Stopping production writers for a consistent canonical export."
|
||||||
|
production_compose stop scheduler app
|
||||||
|
production_app_container=$(production_compose ps --all --quiet app)
|
||||||
|
if [ -z "$production_app_container" ]; then
|
||||||
|
echo "The stopped production app container could not be found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
remove_production_snapshot=true
|
||||||
|
production_compose run --rm --no-deps app \
|
||||||
|
node scripts/isac-data-export.mjs "$production_snapshot_path"
|
||||||
|
docker cp "$production_app_container:$production_snapshot_path" "$snapshot_path"
|
||||||
|
production_compose run --rm --no-deps app rm -f "$production_snapshot_path"
|
||||||
|
remove_production_snapshot=false
|
||||||
|
production_compose up -d --no-build app scheduler
|
||||||
|
|
||||||
|
echo "Stopping staging writers before its automatic full and ISAC safety backups."
|
||||||
|
staging_compose stop scheduler app
|
||||||
|
staging_app_container=$(staging_compose ps --all --quiet app)
|
||||||
|
if [ -z "$staging_app_container" ]; then
|
||||||
|
echo "The stopped staging app container could not be found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
remove_staging_snapshot=true
|
||||||
|
docker cp "$snapshot_path" "$staging_app_container:$staging_snapshot_path"
|
||||||
|
staging_compose run --rm --no-deps app \
|
||||||
|
node scripts/isac-data-import.mjs "$staging_snapshot_path" --confirm-replace-isac-data
|
||||||
|
staging_compose run --rm --no-deps app rm -f "$staging_snapshot_path"
|
||||||
|
remove_staging_snapshot=false
|
||||||
|
rm -f "$snapshot_path"
|
||||||
|
|
||||||
|
echo "Production ISAC data was safely synchronized into staging."
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Community content moderation
|
||||||
|
|
||||||
|
SHD.build applies one moderation lifecycle to public comments, profile
|
||||||
|
biographies, custom avatars, and Guides. The shared server service lives in
|
||||||
|
`src/lib/server/community-moderation.ts`. Content save actions must use that
|
||||||
|
service before publication.
|
||||||
|
|
||||||
|
Public database records also host one moderated discussion thread. The thread
|
||||||
|
uses the existing `comment` content type and accepts only visible comments from
|
||||||
|
authenticated Agents. Community moderators may pin one visible comment as the
|
||||||
|
record's Community Note. Pinning and unpinning append `community_note.pinned`
|
||||||
|
and `community_note.unpinned` audit events; a pinned comment remains ordinary
|
||||||
|
community text and follows the same removal rules as every other comment.
|
||||||
|
|
||||||
|
## Publication decisions
|
||||||
|
|
||||||
|
The moderation service combines local rules with the configured provider result:
|
||||||
|
|
||||||
|
- Clean content publishes without human review when automatic publication is
|
||||||
|
enabled.
|
||||||
|
- Banned terms, PII checks, link limits, provider flags, and configured score
|
||||||
|
thresholds send content to Community Oversight.
|
||||||
|
- Provider failures send content to Oversight when fail-closed behavior is
|
||||||
|
enabled.
|
||||||
|
- An allow term can suppress the matching local term rule. It cannot override a
|
||||||
|
provider flag or threshold.
|
||||||
|
- A user report sends visible content to Oversight. One report does not hide the
|
||||||
|
content because reports can be mistaken or abusive.
|
||||||
|
|
||||||
|
The service records every automated scan in `content_moderation_scans`.
|
||||||
|
Automated scans do not write to the human ISAC audit log. Human policy changes
|
||||||
|
and review decisions use the shared audit utility.
|
||||||
|
|
||||||
|
## Content storage
|
||||||
|
|
||||||
|
Comments use an explicit status:
|
||||||
|
|
||||||
|
- `visible`: public and approved
|
||||||
|
- `pending_review`: hidden while Oversight decides
|
||||||
|
- `removed`: unavailable after author removal or an Oversight decision
|
||||||
|
|
||||||
|
Long-form content uses immutable pending revisions. Profile biographies keep
|
||||||
|
text candidates in `moderated_content_revisions`. Guides use the complete
|
||||||
|
structured snapshot in `article_revisions`, including title, deck, document,
|
||||||
|
tags, media references, and Build snapshots. The public article keeps its last
|
||||||
|
approved revision while another revision waits for review. Rejection retains the
|
||||||
|
submitted revision and evidence without publishing it.
|
||||||
|
|
||||||
|
News articles do not use automated content moderation. The `news.manage`
|
||||||
|
permission gates News creation, editing, publication, and deletion. Comments on
|
||||||
|
News articles remain community content and use the standard comment moderation
|
||||||
|
lifecycle.
|
||||||
|
|
||||||
|
Custom avatar uploads remain active after a clean image scan. A flagged image or
|
||||||
|
fail-closed scan error creates an `avatar_reviews` item and is unavailable
|
||||||
|
through the public avatar route until approval. An Agent report also creates a
|
||||||
|
review item, but leaves an already-published avatar visible while Oversight
|
||||||
|
decides.
|
||||||
|
|
||||||
|
## ISAC responsibilities
|
||||||
|
|
||||||
|
ISAC Site Controls stores the site-wide policy:
|
||||||
|
|
||||||
|
- provider scanning and per-content enablement;
|
||||||
|
- clean-content and provider-failure behavior;
|
||||||
|
- provider model;
|
||||||
|
- PII, link, and duplicate checks;
|
||||||
|
- category review and priority thresholds;
|
||||||
|
- allowed and banned local terms.
|
||||||
|
|
||||||
|
The term registry accepts single rules and batch imports. Batch imports use one
|
||||||
|
term or phrase per line, accept commas and semicolons as separators, infer word
|
||||||
|
or phrase matching, and add no more than 250 unique rules. Word rules match
|
||||||
|
exact forms: `kill` does not match `kills`, `killed`, or `killing`. The import
|
||||||
|
skips duplicates, records each created rule in the ISAC audit log, and leaves
|
||||||
|
the batch editor open for the next list. The registry provides server-side
|
||||||
|
search and displays 25 rules per page by default.
|
||||||
|
|
||||||
|
Each Site Controls category has its own save action. A save validates and
|
||||||
|
updates only that category, increments the moderation policy version when
|
||||||
|
applicable, and records a specific ISAC audit event with before and after
|
||||||
|
evidence. Unsaved changes in other categories remain local drafts.
|
||||||
|
|
||||||
|
Site Controls requires `site.settings.manage`. Community Oversight handles text
|
||||||
|
decisions through the existing comment and profile permissions. Avatar Oversight
|
||||||
|
handles custom images through `avatars.review`.
|
||||||
|
|
||||||
|
Moderators must record a reason when they approve, reject, remove, restore,
|
||||||
|
dismiss, or resolve a queued item. The action must append an audit event after
|
||||||
|
the content and review records change.
|
||||||
|
|
||||||
|
## User reports
|
||||||
|
|
||||||
|
Authenticated Agents can report visible comments, biographies, and avatars they
|
||||||
|
do not own. Registered and anonymous visitors can report visible Guides. A Guide
|
||||||
|
report records the exact published `article_revision_id`; a decision cannot hide
|
||||||
|
a newer revision. Anonymous reports use a random HTTP-only browser token whose
|
||||||
|
domain-separated SHA-256 hash supplies duplicate suppression. The server applies
|
||||||
|
rate limits and requests Turnstile verification after repeated anonymous
|
||||||
|
activity. SHD.build does not use device fingerprinting or retain a raw IP
|
||||||
|
address as the reporter identity.
|
||||||
|
|
||||||
|
A reviewer either dismisses the report and keeps the content or resolves the
|
||||||
|
report through a moderation action. Report details must not copy authentication
|
||||||
|
secrets or unnecessary personal information into the audit log.
|
||||||
|
|
||||||
|
## Adding a public UGC field
|
||||||
|
|
||||||
|
Guide authoring and later public text fields must complete this checklist:
|
||||||
|
|
||||||
|
1. Normalize and validate the submitted text before scanning.
|
||||||
|
2. Call `moderateCommunityText` with a supported content type.
|
||||||
|
3. Place the shared `ModerationDisclosure` beside the submission control.
|
||||||
|
4. Publish only an `approved` result.
|
||||||
|
5. Store held long-form text as an immutable revision so the last approved
|
||||||
|
version remains public.
|
||||||
|
6. Call `recordCommunityModerationScan` with the stable content ID, exact
|
||||||
|
article revision, author, hash, policy version, provider evidence, and
|
||||||
|
local-rule matches.
|
||||||
|
7. Add the content type to the Community Oversight queue and permission checks.
|
||||||
|
8. Add the shared report control to the public rendering.
|
||||||
|
9. Delay follower, owner, or reply notifications until the content becomes
|
||||||
|
public.
|
||||||
|
10. Add approval, rejection, report dismissal, author notification, and audit
|
||||||
|
tests.
|
||||||
|
11. Confirm every public query selects approved content explicitly.
|
||||||
|
|
||||||
|
Do not add a route-specific word filter, provider client, threshold table, or
|
||||||
|
moderation status vocabulary. Extend the shared service and schema when a new
|
||||||
|
content type needs more evidence or a different review presentation.
|
||||||
|
|
||||||
|
## Operations and calibration
|
||||||
|
|
||||||
|
The API key stays in `OPENAI_MODERATION_API_KEY`. The server can use
|
||||||
|
`OPENAI_MECHANIC_API_KEY` as a temporary fallback, but production should use a
|
||||||
|
separate moderation credential.
|
||||||
|
|
||||||
|
Public submission notices link to the Terms & Privacy page instead of
|
||||||
|
summarizing provider data handling beside each form. The Terms & Privacy page
|
||||||
|
must not promise that OpenAI never retains or uses moderation inputs. It states
|
||||||
|
that OpenAI controls downstream processing under its then-current terms, data
|
||||||
|
controls, and account settings. If SHD.build enables an OpenAI data-sharing
|
||||||
|
option, update the legal copy before sending user content under that setting.
|
||||||
|
|
||||||
|
Threshold changes increment the policy version. Stored scans retain their model
|
||||||
|
and policy version so reviewers can interpret older decisions. Test threshold
|
||||||
|
changes against representative Division terminology before activation, including
|
||||||
|
weapon names, damage descriptions, kills, status effects, and build strategy
|
||||||
|
language.
|
||||||
|
|
||||||
|
Database migrations that add moderation states or revision storage follow the
|
||||||
|
repository database-preservation procedure: stop this project's development
|
||||||
|
server, verify the database path, create and verify a JSON backup, migrate,
|
||||||
|
check foreign keys and critical record counts, then restart and health-check.
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
# Container, staging, and production deployment
|
||||||
|
|
||||||
|
SHD.build runs in a Node 24 container through the official SvelteKit Node
|
||||||
|
adapter. The image includes application code, migration and notification
|
||||||
|
scripts, the Infisical CLI, and native runtime dependencies. Databases, backups,
|
||||||
|
user uploads, and Universal Auth bootstrap credentials remain outside the image.
|
||||||
|
|
||||||
|
## Persistent data
|
||||||
|
|
||||||
|
The staging compose project uses the named volume `shd-build-stage-data`,
|
||||||
|
mounted at `/app/data`. It contains:
|
||||||
|
|
||||||
|
- `shd-build-staging.db` and its embedded-database support files;
|
||||||
|
- timestamped JSON backups under `backups/`;
|
||||||
|
- normalized user avatars under `avatars/`;
|
||||||
|
- uploaded game-data icons under `game-icons/`.
|
||||||
|
|
||||||
|
Keep this volume during routine deployments. `docker compose down` without
|
||||||
|
`--volumes` preserves it. Before a manual backup or restore, stop the `app` and
|
||||||
|
`scheduler` services because both access the embedded database.
|
||||||
|
|
||||||
|
## Staging environment
|
||||||
|
|
||||||
|
Staging defaults to:
|
||||||
|
|
||||||
|
- Public origin: `https://stage.shd.build`
|
||||||
|
- Reverse-proxy upstream: host port `5569`
|
||||||
|
- Database: `/app/data/shd-build-staging.db`
|
||||||
|
- Passkey relying-party ID: `shd.build`
|
||||||
|
- Adapter canonical origin: `https://stage.shd.build`
|
||||||
|
|
||||||
|
Guide ratings and reports use Cloudflare Turnstile after repeated anonymous
|
||||||
|
activity. Configure both values under `/app` in each Infisical environment:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TURNSTILE_SITE_KEY=<public site key>
|
||||||
|
TURNSTILE_SECRET_KEY=<server verification secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
Register the exact staging and production hostnames in the Turnstile widget. The
|
||||||
|
browser receives only `TURNSTILE_SITE_KEY`. The server sends a challenge token
|
||||||
|
and the request address to Cloudflare for verification; it never exposes
|
||||||
|
`TURNSTILE_SECRET_KEY` or stores the raw address as visitor identity. Without a
|
||||||
|
valid secret, a visitor who reaches the challenge threshold cannot submit more
|
||||||
|
anonymous ratings or Guide reports.
|
||||||
|
|
||||||
|
Edit staging configuration in Infisical project
|
||||||
|
`2ee94b27-e599-4194-9797-3372d42e53f6`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Environment: staging
|
||||||
|
Application path: /app
|
||||||
|
Scheduler path: /shared/scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `https://stage.shd.build/auth/discord/callback` to the Discord application's
|
||||||
|
allowed OAuth redirect URIs. Point the staging DNS record at the deployment host
|
||||||
|
and configure the host's TLS reverse proxy to forward `stage.shd.build` to port
|
||||||
|
`5569` on the staging host. Docker publishes this port on the host interfaces so
|
||||||
|
a containerized reverse proxy or tunnel can reach it; firewall the port to
|
||||||
|
trusted LAN/tunnel sources when the host is reachable from other networks.
|
||||||
|
|
||||||
|
The trusted staging runner writes separate mode-`0600` Universal Auth client-ID
|
||||||
|
and client-secret files for the app and scheduler. Compose mounts each pair only
|
||||||
|
into its assigned service. Each container exchanges those credentials for a
|
||||||
|
short-lived access token and writes the assigned paths to mode-`0600` temporary
|
||||||
|
JSON files. The entrypoint rejects unknown secret names, deletes the token,
|
||||||
|
transfers the exports to the dedicated `shd-runtime` UID 10001, and drops root
|
||||||
|
before parsing them. The launcher deletes the files before validation,
|
||||||
|
migration, or startup. Startup fails if the mounted credential files are
|
||||||
|
readable by UID 10001 or by their group or other users. Runtime-created data
|
||||||
|
retains group access for rollback to the previous container UID.
|
||||||
|
|
||||||
|
| Machine identity | Environment | Injected paths | Gitea variable | Gitea secret |
|
||||||
|
| -------------------------------- | ----------- | --------------------------- | ------------------------------------------ | ---------------------------------------------- |
|
||||||
|
| `shd-build-staging-app` | `staging` | `/app`, `/shared/scheduler` | `INFISICAL_STAGING_APP_CLIENT_ID` | `INFISICAL_STAGING_APP_CLIENT_SECRET` |
|
||||||
|
| `shd-build-staging-scheduler` | `staging` | `/shared/scheduler` | `INFISICAL_STAGING_SCHEDULER_CLIENT_ID` | `INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET` |
|
||||||
|
| `shd-build-production-app` | `prod` | `/app`, `/shared/scheduler` | `INFISICAL_PRODUCTION_APP_CLIENT_ID` | `INFISICAL_PRODUCTION_APP_CLIENT_SECRET` |
|
||||||
|
| `shd-build-production-scheduler` | `prod` | `/shared/scheduler` | `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID` | `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET` |
|
||||||
|
|
||||||
|
The self-hosted Free plan allows five machine identities and does not include
|
||||||
|
Custom Project Roles. The four runtime identities use the built-in Viewer role,
|
||||||
|
which can read the complete project. Service-specific paths limit injected
|
||||||
|
values, but they do not restrict what a stolen root credential could request.
|
||||||
|
Use an Enterprise license with Custom Project Roles, or split runtime scopes
|
||||||
|
into separate projects, before treating identity authorization as least
|
||||||
|
privilege.
|
||||||
|
|
||||||
|
An Infisical outage does not stop a running container. A new container or direct
|
||||||
|
restart fails closed until `https://secrets.jb.codes` becomes available and the
|
||||||
|
identity can authenticate.
|
||||||
|
|
||||||
|
Keep the old `.env.*.runtime` files and `DOTENV_PRIVATE_KEY_*` Gitea secrets
|
||||||
|
only through the first successful staging and production cutovers. The rollback
|
||||||
|
path may need them to start the previous image. After both environments pass
|
||||||
|
direct restart checks, rotate every migrated provider credential in Infisical,
|
||||||
|
remove the old dotenv private keys and `SCHEDULER_TOKEN_PRODUCTION` from Gitea,
|
||||||
|
and delete local and hosted dotenv key material. Removing encrypted files from
|
||||||
|
the current Git tree does not revoke values stored in Git history.
|
||||||
|
|
||||||
|
The staging container disables default-user seeding. On a brand-new staging
|
||||||
|
database, the existing bootstrap policy grants Admin to the first real account
|
||||||
|
that signs in instead of writing generated credentials into deployment logs.
|
||||||
|
|
||||||
|
## Gitea Actions runner
|
||||||
|
|
||||||
|
Gitea Actions requires an `act_runner`. Register a trusted runner on the staging
|
||||||
|
host from the repository's **Settings → Actions → Runners** page. The host needs
|
||||||
|
Node/npm, Docker Engine, and Docker Compose v2. The runner account must be able
|
||||||
|
to use Docker and write to the deployment directory.
|
||||||
|
|
||||||
|
Use runner labels that provide an isolated validation job and a host deployment
|
||||||
|
job:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ubuntu-latest:docker://node:24-bookworm,stage-deploy:host
|
||||||
|
```
|
||||||
|
|
||||||
|
The `stage-deploy:host` label is intentional: the deployment step needs the
|
||||||
|
staging host's Docker daemon and persistent named volume. Only attach this
|
||||||
|
trusted runner to repositories allowed to deploy to that host. Gitea warns that
|
||||||
|
Docker daemon access is effectively host-level authority.
|
||||||
|
|
||||||
|
Prepare the default deployment directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -d -o act_runner -g act_runner -m 0750 /opt/shd-build-stage
|
||||||
|
sudo usermod -aG docker act_runner
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the runner session after adding it to the Docker group.
|
||||||
|
|
||||||
|
## Repository Actions configuration
|
||||||
|
|
||||||
|
In `https://git.jb.codes/jb/shd.build/settings/actions`:
|
||||||
|
|
||||||
|
1. Add repository secrets `INFISICAL_STAGING_APP_CLIENT_SECRET` and
|
||||||
|
`INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET`.
|
||||||
|
2. Add repository variables `INFISICAL_STAGING_APP_CLIENT_ID` and
|
||||||
|
`INFISICAL_STAGING_SCHEDULER_CLIENT_ID`.
|
||||||
|
3. Optionally add repository variable `STAGE_DEPLOY_PATH`. It defaults to
|
||||||
|
`/opt/shd-build-stage`.
|
||||||
|
4. Optionally add repository variable `STAGE_PORT`. It defaults to `5569`.
|
||||||
|
5. Ensure Actions are enabled and the `stage-deploy` runner is online.
|
||||||
|
|
||||||
|
`.gitea/workflows/deploy-staging.yml` runs for pushes to `main` and manual
|
||||||
|
dispatches. The trusted staging runner builds each image locally with a tag that
|
||||||
|
contains the commit, workflow run, and attempt, so the workflow needs no
|
||||||
|
registry credentials and a retry cannot overwrite the active image. Gitea
|
||||||
|
assigns `gitea.run_number` to `SHD_BUILD_NUMBER`; each new workflow run receives
|
||||||
|
the next integer, while retries of the same run retain their number.
|
||||||
|
|
||||||
|
Staging and production deployments, ISAC exports, and production-to-staging
|
||||||
|
synchronization share the `shd-build-host-maintenance` concurrency group. Gitea
|
||||||
|
runs only one of these database maintenance workflows on the deployment host at
|
||||||
|
a time and never cancels one in progress.
|
||||||
|
|
||||||
|
## Deployment lifecycle
|
||||||
|
|
||||||
|
The workflow stages Compose, credentials, and release metadata in an isolated
|
||||||
|
candidate directory. It leaves the canonical deployment files unchanged until
|
||||||
|
the candidate passes preflight, backup, and migration. The workflow then:
|
||||||
|
|
||||||
|
1. installs dependencies, runs `npm run check` and `npm test`, and produces the
|
||||||
|
Node build;
|
||||||
|
2. builds the candidate container while the current site remains online;
|
||||||
|
3. starts one-off app and scheduler containers to verify Infisical access and
|
||||||
|
validate the candidate environment;
|
||||||
|
4. stops only the staging application and notification scheduler;
|
||||||
|
5. creates a timestamped JSON backup if the staging database exists;
|
||||||
|
6. applies additive, repeatable migrations in a one-off container;
|
||||||
|
7. promotes the candidate files to the canonical deployment paths;
|
||||||
|
8. starts the application and scheduler with the new image;
|
||||||
|
9. validates the app environment and reruns the repeatable migration in the app
|
||||||
|
entrypoint before serving;
|
||||||
|
10. verifies `/api/health` and scheduler liveness;
|
||||||
|
11. restores the previous canonical files and verifies the previous app and
|
||||||
|
scheduler after a failed stop, backup, migration, startup, or health check.
|
||||||
|
|
||||||
|
On a failed health check, the deploy script prints the final 150 application log
|
||||||
|
lines and exits unsuccessfully. It does not create a separate log artifact.
|
||||||
|
|
||||||
|
The outage covers backup, migration, and container startup. Migrations may also
|
||||||
|
remove records that use retired data formats when a release explicitly ends
|
||||||
|
compatibility. The deployment backup remains the recovery source for those
|
||||||
|
records; the application does not repair or reinterpret them at runtime. The
|
||||||
|
scheduler calls a bearer-authenticated endpoint on the application service once
|
||||||
|
per minute. Scheduled message delivery therefore uses the web process's existing
|
||||||
|
embedded-database connection instead of opening the locked database from a
|
||||||
|
second process. The endpoint runs automated system messages, Oversight
|
||||||
|
consolidation, email digest checks, and the Ubisoft News importer. Docker does
|
||||||
|
not publish the endpoint through a host port.
|
||||||
|
|
||||||
|
When enabled in production, the Ubisoft importer keeps its own persisted due
|
||||||
|
time, so the one-minute tick starts a feed synchronization no more than once per
|
||||||
|
hour after a successful run. When a synchronization fails, the importer stores
|
||||||
|
the last error and consecutive failure count, then retries after 15 minutes with
|
||||||
|
exponential backoff capped at six hours. Source items that disappear from the
|
||||||
|
feed remain published; the importer performs no automatic removal. The scheduler
|
||||||
|
runs notification delivery before it starts the bounded Ubisoft request, so a
|
||||||
|
slow provider does not postpone notifications for the current tick.
|
||||||
|
|
||||||
|
The runtime launcher sets `UBISOFT_NEWS_IMPORT_ENABLED=true` for production and
|
||||||
|
`false` for staging. Development does not set the flag, so the importer remains
|
||||||
|
disabled because it requires the exact value `true`. The application process
|
||||||
|
evaluates the flag when it handles `/api/internal/scheduler`.
|
||||||
|
|
||||||
|
## Prelaunch operation
|
||||||
|
|
||||||
|
The launch-control migration creates `site_launch_settings` with these defaults
|
||||||
|
for a database that does not contain the row:
|
||||||
|
|
||||||
|
- Coming Soon: enabled
|
||||||
|
- Account creation: enabled
|
||||||
|
- News, Database, Builds, Guides, and Builder: enabled
|
||||||
|
|
||||||
|
The migration uses an insert-if-missing seed. Later deployments preserve the
|
||||||
|
operator's settings, and configuration changes take effect without a restart. A
|
||||||
|
full environment bootstrap copies the source row. Canonical ISAC game-data
|
||||||
|
imports leave the target environment's launch settings unchanged.
|
||||||
|
|
||||||
|
Use this operator flow after the first deployment:
|
||||||
|
|
||||||
|
1. Open `/sign-in`; the launch gate keeps that route available.
|
||||||
|
2. Sign in with an existing Administrator account.
|
||||||
|
3. Open ISAC → System Configuration → Site Controls.
|
||||||
|
4. Set account creation and public-section availability.
|
||||||
|
5. Review the complete change set, then confirm it.
|
||||||
|
6. Use `PREVIEW PAGE` to inspect `/coming-soon` with the authenticated
|
||||||
|
`ENTER FULL SITE` return action. The preview lists each section's current
|
||||||
|
availability.
|
||||||
|
7. Verify `/coming-soon` and the affected public routes in a signed-out browser
|
||||||
|
session before public release. Sign in and verify the same disabled routes
|
||||||
|
remain unavailable.
|
||||||
|
|
||||||
|
Keep Coming Soon enabled while preparing production. Section controls can be set
|
||||||
|
in advance because the launch gate takes precedence for signed-out traffic. Set
|
||||||
|
Account Creation to Disabled if prelaunch access must remain limited to existing
|
||||||
|
identities. Switch the Launch Gate to Public Launch after verification. Disabled
|
||||||
|
sections remain absent from navigation and unavailable by direct URL for every
|
||||||
|
user, including authenticated agents. Authentication bypasses only the Coming
|
||||||
|
Soon gate.
|
||||||
|
|
||||||
|
Turn Coming Soon on to restore the promotional gate. Site-control changes append
|
||||||
|
`system_configuration / site_access.updated` audit events, including the
|
||||||
|
operator and complete before/after settings.
|
||||||
|
|
||||||
|
## Initial development-data bootstrap
|
||||||
|
|
||||||
|
Initialize the first staging release from the development database, including
|
||||||
|
users, roles, builds, guides, proposals, audit history, avatars, and uploaded
|
||||||
|
game-data icons. The staging importer refuses a target database that contains
|
||||||
|
users, builds, audits, or proposals. It also requires the source and target
|
||||||
|
table sets to match exactly; snapshots with retired or unknown tables are
|
||||||
|
rejected.
|
||||||
|
|
||||||
|
First stop this project's development server. The embedded database must not be
|
||||||
|
open while its complete transfer bundle is created:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run stage:bootstrap:export
|
||||||
|
```
|
||||||
|
|
||||||
|
The command writes a timestamped, ignored JSON bundle under `data/backups/`. The
|
||||||
|
bundle includes user records and password hashes. Transfer it over an encrypted
|
||||||
|
channel, keep it out of Git, then copy it to the staging host and run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/opt/shd-build-stage/bootstrap-staging.sh \
|
||||||
|
/secure/path/shd-stage-bootstrap-YYYY-MM-DDTHH-MM-SSZ.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The bootstrap flow requires both services to stop, copies the bundle into the
|
||||||
|
private data volume, assigns it to the runtime UID, creates a safety backup,
|
||||||
|
verifies that staging is unused, imports the entire development database and
|
||||||
|
uploads, deletes the volume copy, and restarts both services. Delete the host
|
||||||
|
transfer copy after verifying the imported record counts and critical entities.
|
||||||
|
The copy under the local ignored backup directory remains the recovery source.
|
||||||
|
|
||||||
|
Because this is a full initial clone, development session tokens and account
|
||||||
|
data also enter staging. Staging and development remain independent from that
|
||||||
|
point forward.
|
||||||
|
|
||||||
|
## Pulling canonical ISAC data into development
|
||||||
|
|
||||||
|
Use the manually triggered **Export staging ISAC data** workflow in Gitea
|
||||||
|
Actions. It briefly stops staging database writers, exports only canonical
|
||||||
|
builder-source tables plus uploaded game-data icons, restarts staging, and
|
||||||
|
publishes a `shd-stage-isac-data` artifact for 14 days.
|
||||||
|
|
||||||
|
Download `isac-stage.json` from that workflow run, stop this project's
|
||||||
|
development server, and run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run isac:import -- /path/to/isac-stage.json --confirm-replace-isac-data
|
||||||
|
```
|
||||||
|
|
||||||
|
The importer automatically creates both a complete development database backup
|
||||||
|
and an ISAC-only snapshot before replacing canonical data. It does **not**
|
||||||
|
replace local users, roles, sessions, builds, guides, comments, notifications,
|
||||||
|
proposals, contributor agreements, audit history, or system-message settings.
|
||||||
|
Restart development only after the command reports both backup paths and the
|
||||||
|
imported row/icon counts.
|
||||||
|
|
||||||
|
For a local read-only snapshot without importing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run isac:export
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual staging operations
|
||||||
|
|
||||||
|
From the deployment directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose --env-file .env.deploy -f compose.staging.yml ps
|
||||||
|
docker compose --env-file .env.deploy -f compose.staging.yml logs -f app
|
||||||
|
docker compose --env-file .env.deploy -f compose.staging.yml stop scheduler app
|
||||||
|
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps app node scripts/db-backup.mjs
|
||||||
|
docker compose --env-file .env.deploy -f compose.staging.yml up -d app scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not use `docker compose down --volumes` for staging. That deletes the named
|
||||||
|
volume containing the embedded database and uploads.
|
||||||
|
|
||||||
|
## Production promotion
|
||||||
|
|
||||||
|
Production uses a separate compose project, volume, database filename, runtime
|
||||||
|
environment, port, and deployment workflow:
|
||||||
|
|
||||||
|
- Public origin: `https://shd.build`
|
||||||
|
- Reverse-proxy upstream: host port `3102`
|
||||||
|
- Deployment directory: `/opt/shd-build-production`
|
||||||
|
- Database: `/app/data/shd-build-production.db`
|
||||||
|
- Persistent volume: `shd-build-production-data`
|
||||||
|
|
||||||
|
Routine pushes preserve production data unless an included migration removes a
|
||||||
|
retired prototype format. The production deploy stops its application and
|
||||||
|
scheduler, creates a timestamped JSON backup, applies repeatable migrations,
|
||||||
|
restarts, and verifies database health. Keep `docker compose down --volumes`
|
||||||
|
away from production.
|
||||||
|
|
||||||
|
Add these repository Action secrets:
|
||||||
|
|
||||||
|
- `INFISICAL_PRODUCTION_APP_CLIENT_SECRET`
|
||||||
|
- `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET`
|
||||||
|
|
||||||
|
Add the matching repository variables:
|
||||||
|
|
||||||
|
- `INFISICAL_PRODUCTION_APP_CLIENT_ID`
|
||||||
|
- `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID`
|
||||||
|
|
||||||
|
Optional repository variables are `PRODUCTION_DEPLOY_PATH` (default
|
||||||
|
`/opt/shd-build-production`) and `PRODUCTION_PORT` (default `3102`). The
|
||||||
|
`deploy-production.yml` workflow deploys production on pushes to `main` and
|
||||||
|
manual dispatches using the trusted `stage-deploy` host runner. Its Gitea
|
||||||
|
workflow run number supplies the production `SHD_BUILD_NUMBER`.
|
||||||
|
|
||||||
|
Prepare the host directory once from an interactive SSH session:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -d -o act_runner -g act_runner -m 0750 /opt/shd-build-production
|
||||||
|
```
|
||||||
|
|
||||||
|
Store a separate production `SCHEDULER_TOKEN` under Infisical path
|
||||||
|
`/shared/scheduler`. The application and scheduler fetch the same value from
|
||||||
|
that path. Do not reuse the staging token.
|
||||||
|
|
||||||
|
### Initial production bootstrap
|
||||||
|
|
||||||
|
The production bootstrap clones current development data and uploads. Stop this
|
||||||
|
project's development server before creating the bundle:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run production:bootstrap:export
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the resulting ignored JSON bundle to the deployment host over SSH, then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/opt/shd-build-production/bootstrap-production.sh \
|
||||||
|
/secure/path/shd-production-bootstrap-YYYY-MM-DDTHH-MM-SSZ.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The bootstrap script requires both services to stop, stages the bundle in the
|
||||||
|
private data volume with runtime-only access, and removes that copy after the
|
||||||
|
import. The importer verifies the bundle target, creates a production safety
|
||||||
|
backup, and refuses to replace a production database containing users, builds,
|
||||||
|
audit events, or proposals. Remove the remote host copy after verification.
|
||||||
|
|
||||||
|
### Promote one News article
|
||||||
|
|
||||||
|
Export a locally authored News article by ID or slug while the development
|
||||||
|
server is stopped:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run article:export -- <article-id-or-slug>
|
||||||
|
```
|
||||||
|
|
||||||
|
The ignored bundle contains the article draft, immutable revisions, search row,
|
||||||
|
managed-image metadata, and image bytes. It excludes comments, reports, user
|
||||||
|
records, Builds, and ISAC data.
|
||||||
|
|
||||||
|
After deploying the matching application schema, stop the production app and
|
||||||
|
scheduler. Copy the transferred bundle into `/app/data` with `docker cp`, assign
|
||||||
|
it to `shd-runtime:node` with mode `0600` through a root one-off container, then
|
||||||
|
run `scripts/article-transfer.mjs import` with `ARTICLE_IMPORT_ENABLED=true` and
|
||||||
|
`--confirm-article-import`. The importer creates a production database backup
|
||||||
|
before opening the database, resolves the article owner by stable ID or
|
||||||
|
username, rejects ID and slug conflicts, verifies image checksums, and appends
|
||||||
|
`editorial_content / news.promoted`. Remove both bundle copies, restart the app
|
||||||
|
and scheduler, and health-check both services.
|
||||||
|
|
||||||
|
### Pull production ISAC data into development
|
||||||
|
|
||||||
|
Manually run **Export production ISAC data** in Gitea Actions and download its
|
||||||
|
`shd-production-isac-data` artifact. Stop the local development server, then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run isac:import -- /path/to/isac-production.json --confirm-replace-isac-data
|
||||||
|
```
|
||||||
|
|
||||||
|
The importer creates a complete local database backup and an ISAC-only snapshot
|
||||||
|
before replacing canonical game data. It leaves accounts, content, proposals,
|
||||||
|
notifications, settings, and audit history untouched.
|
||||||
|
|
||||||
|
### Pull production ISAC data into staging
|
||||||
|
|
||||||
|
Manually run **Replace staging ISAC data from production** in Gitea Actions. The
|
||||||
|
workflow exports a consistent production snapshot, restarts production, then
|
||||||
|
stops staging and imports it. The staging importer creates full and ISAC-only
|
||||||
|
safety backups before replacement. Deployments do not run this flow.
|
||||||
|
|
||||||
|
### Promote managed images without replacing other data
|
||||||
|
|
||||||
|
Image Library assets use stable IDs and can be promoted additively from local
|
||||||
|
development. Export a bundle after finishing local uploads and metadata edits:
|
||||||
|
|
||||||
|
Set the server-only `REMOVE_BG_API_KEY` under `/app` in each Infisical
|
||||||
|
environment that accepts ISAC uploads. The server sends it only in the
|
||||||
|
`X-Api-Key` header. Transparent icon uploads skip remove.bg. Opaque sources use
|
||||||
|
its preview output, then pass through local trimming, white normalization, WebP
|
||||||
|
conversion, and storage.
|
||||||
|
|
||||||
|
The application stores a remove.bg capacity snapshot and refreshes it from the
|
||||||
|
internal scheduler. `REMOVE_BG_CREDIT_CHECK_HOURS` controls the account lookup
|
||||||
|
interval and defaults to six hours. Low-capacity alerts require both remaining
|
||||||
|
pools to cross their thresholds: `REMOVE_BG_LOW_CREDIT_THRESHOLD` defaults to
|
||||||
|
five paid credits, and `REMOVE_BG_LOW_FREE_CALL_THRESHOLD` defaults to ten free
|
||||||
|
calls. Alerts are mandatory ISAC notifications for active Administrators and are
|
||||||
|
deduplicated for each warning or critical episode.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run images:export
|
||||||
|
```
|
||||||
|
|
||||||
|
The command writes a timestamped JSON bundle under `data/backups/`. It contains
|
||||||
|
only managed-image metadata and processed WebP files. Transfer that bundle to
|
||||||
|
the deployment host, stop the target `app` and `scheduler` services so the
|
||||||
|
embedded database is unlocked, and run the merge using the target image:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Staging
|
||||||
|
cd /opt/shd-build-stage
|
||||||
|
docker compose --env-file .env.deploy -f compose.staging.yml stop scheduler app
|
||||||
|
docker compose --env-file .env.deploy -f compose.staging.yml create app
|
||||||
|
app_container=$(docker compose --env-file .env.deploy -f compose.staging.yml ps --all --quiet app)
|
||||||
|
docker cp /secure/path/managed-images.json "$app_container:/app/data/.managed-images-import.json"
|
||||||
|
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \
|
||||||
|
--entrypoint sh --user root app -c \
|
||||||
|
'chown shd-runtime:node /app/data/.managed-images-import.json && chmod 600 /app/data/.managed-images-import.json'
|
||||||
|
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps app \
|
||||||
|
node scripts/managed-images-import.mjs /app/data/.managed-images-import.json --confirm-merge
|
||||||
|
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \
|
||||||
|
--entrypoint rm --user root app -f /app/data/.managed-images-import.json
|
||||||
|
docker compose --env-file .env.deploy -f compose.staging.yml up -d app scheduler
|
||||||
|
|
||||||
|
# Production
|
||||||
|
cd /opt/shd-build-production
|
||||||
|
docker compose --env-file .env.deploy -f compose.production.yml stop scheduler app
|
||||||
|
docker compose --env-file .env.deploy -f compose.production.yml create app
|
||||||
|
app_container=$(docker compose --env-file .env.deploy -f compose.production.yml ps --all --quiet app)
|
||||||
|
docker cp /secure/path/managed-images.json "$app_container:/app/data/.managed-images-import.json"
|
||||||
|
docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \
|
||||||
|
--entrypoint sh --user root app -c \
|
||||||
|
'chown shd-runtime:node /app/data/.managed-images-import.json && chmod 600 /app/data/.managed-images-import.json'
|
||||||
|
docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps app \
|
||||||
|
node scripts/managed-images-import.mjs /app/data/.managed-images-import.json --confirm-merge
|
||||||
|
docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \
|
||||||
|
--entrypoint rm --user root app -f /app/data/.managed-images-import.json
|
||||||
|
docker compose --env-file .env.deploy -f compose.production.yml up -d app scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
Each import creates a full target database backup before changes. The importer
|
||||||
|
adds new stable IDs, skips identical assets, and blocks conflicting records.
|
||||||
|
After reviewing an intentional metadata or file replacement, add
|
||||||
|
`--update-existing`. Imported changes append `image.imported` or
|
||||||
|
`image.import_updated` audit events. Remove the transferred bundle after
|
||||||
|
verifying both environments because it may contain unpublished visual assets and
|
||||||
|
creator identifiers.
|
||||||
|
|
||||||
|
The staging and production npm import commands fetch `/app` from Infisical. Run
|
||||||
|
them only where the target database and storage paths are mounted:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run images:import:staging -- /path/to/bundle.json --confirm-merge
|
||||||
|
npm run images:import:production -- /path/to/bundle.json --confirm-merge
|
||||||
|
```
|
||||||
+1901
-591
File diff suppressed because it is too large
Load Diff
+32
-36
@@ -1,25 +1,23 @@
|
|||||||
# Weapon and gear stat level scaling
|
# Weapon and gear stat level scaling
|
||||||
|
|
||||||
This document records the empirically observed rules for adjusting weapon and
|
Use these observed rules to adjust weapon and gear stat ranges by item level.
|
||||||
gear stat ranges by item level. The rules apply independently to both endpoints
|
Apply each rule to both endpoints of a configured range.
|
||||||
of a configured range.
|
|
||||||
|
|
||||||
These formulas are based on in-game observations rather than published game
|
In-game observations, rather than published game mechanics, support these
|
||||||
mechanics. Preserve source observations when adding new samples and revise the
|
formulas. Preserve source observations with new samples and revise constants
|
||||||
constants if a larger data set disproves them.
|
when a larger data set disproves them.
|
||||||
|
|
||||||
## Numeric representation and display
|
## Numeric representation and display
|
||||||
|
|
||||||
Stat ranges must retain their unrounded decimal values. The integer shown by the
|
Store stat ranges as unrounded decimals. The game display truncates them:
|
||||||
game is consistent with truncation:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
displayedValue = floor(rawValue)
|
displayedValue = floor(rawValue)
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not use `ceil`, and do not repeatedly level an already displayed integer.
|
Avoid `ceil` and repeated leveling of displayed integers. Both accumulate
|
||||||
Both approaches accumulate errors. A level-1 value displayed as `0` can still
|
errors. A level-1 display of `0` can hide a positive fractional raw value that
|
||||||
have a positive fractional raw value that becomes visible at a higher level.
|
appears at a higher level.
|
||||||
|
|
||||||
For example, a standard gear item displayed `0 ENG` at level 1 and `2 ENG` at
|
For example, a standard gear item displayed `0 ENG` at level 1 and `2 ENG` at
|
||||||
level 42. With a level-42 multiplier of `3.01`, a raw base near `0.7` explains
|
level 42. With a level-42 multiplier of `3.01`, a raw base near `0.7` explains
|
||||||
@@ -30,12 +28,12 @@ floor(0.7) = 0
|
|||||||
floor(0.7 × 3.01) = 2
|
floor(0.7 × 3.01) = 2
|
||||||
```
|
```
|
||||||
|
|
||||||
When only displayed integers are available, a prediction can be off by one.
|
Predictions from displayed integers can miss by one because truncation loses
|
||||||
That is expected information loss, not evidence for rounding upward.
|
information. That error does not indicate rounding upward.
|
||||||
|
|
||||||
## Shared progression model
|
## Shared progression model
|
||||||
|
|
||||||
The stable portion of both progressions is linear, not compounded:
|
The stable portion of both progressions is linear:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
rawStat(level) = hiddenBase × multiplier(level)
|
rawStat(level) = hiddenBase × multiplier(level)
|
||||||
@@ -48,8 +46,8 @@ fixed `5%` of the hidden base:
|
|||||||
rawStat(level + 1) - rawStat(level) = hiddenBase × 0.05
|
rawStat(level + 1) - rawStat(level) = hiddenBase × 0.05
|
||||||
```
|
```
|
||||||
|
|
||||||
Never calculate a new level as `previousDisplayedValue × 1.05`; that describes
|
Do not calculate a new level as `previousDisplayedValue × 1.05`. That compound
|
||||||
compound growth and does not match the observations.
|
growth model conflicts with the observations.
|
||||||
|
|
||||||
For a range `[minimum, maximum]`, calculate each endpoint separately:
|
For a range `[minimum, maximum]`, calculate each endpoint separately:
|
||||||
|
|
||||||
@@ -58,7 +56,7 @@ scaledMinimum = hiddenMinimum × multiplier(level)
|
|||||||
scaledMaximum = hiddenMaximum × multiplier(level)
|
scaledMaximum = hiddenMaximum × multiplier(level)
|
||||||
```
|
```
|
||||||
|
|
||||||
Only truncate when producing a game-matching display value.
|
Truncate at the game-display boundary.
|
||||||
|
|
||||||
## Gear
|
## Gear
|
||||||
|
|
||||||
@@ -68,9 +66,9 @@ The observed stable gear multiplier is:
|
|||||||
gearMultiplier(level) = 0.91 + 0.05 × level
|
gearMultiplier(level) = 0.91 + 0.05 × level
|
||||||
```
|
```
|
||||||
|
|
||||||
This rule is validated for levels 10 through 50 across Standard, Specialized,
|
Samples across Standard, Specialized, and High-end qualities validate this rule
|
||||||
and High-end samples. Quality changes the hidden stat ranges; the observed
|
for levels 10 through 50. Quality changes hidden stat ranges; the observed
|
||||||
late-level multiplier does not change with quality.
|
late-level multiplier remains the same.
|
||||||
|
|
||||||
Given hidden level-independent endpoints:
|
Given hidden level-independent endpoints:
|
||||||
|
|
||||||
@@ -115,11 +113,10 @@ ENG = floor(0.7 × 3.01) = 2
|
|||||||
|
|
||||||
### Early gear levels
|
### Early gear levels
|
||||||
|
|
||||||
Levels 1 through 9 show a quality-dependent transition and do not follow the
|
Levels 1 through 9 show a quality-dependent transition outside the stable
|
||||||
stable multiplier directly. Do not extrapolate that transition from a single
|
multiplier. Do not extrapolate it from one level-1-to-level-2 change. Until
|
||||||
level-1-to-level-2 change. Until the early curves are sampled more completely,
|
more samples define the early curves, store authoritative ranges or label
|
||||||
store authoritative ranges for those levels or mark calculated results as
|
calculated results as estimates.
|
||||||
estimates.
|
|
||||||
|
|
||||||
## Weapons
|
## Weapons
|
||||||
|
|
||||||
@@ -179,9 +176,9 @@ multiplier, then derive the combined pool bounds by multiplying each endpoint
|
|||||||
by three. Redistribution changes the split between the three cores without
|
by three. Redistribution changes the split between the three cores without
|
||||||
changing the selected item's total allocation.
|
changing the selected item's total allocation.
|
||||||
|
|
||||||
The `1.14` offset is provisional and is currently validated only for the
|
The `1.14` offset remains provisional; observations only validate it from level
|
||||||
level-40-to-level-50 interval. Preserve enough decimal precision to revise it
|
40 to 50. Retain enough decimal precision to revise it without migrating
|
||||||
without migrating rounded data.
|
rounded data.
|
||||||
|
|
||||||
### Weapon example
|
### Weapon example
|
||||||
|
|
||||||
@@ -192,9 +189,9 @@ A weapon changed from `753 DMG` at level 40 to `873 DMG` at level 50:
|
|||||||
floor(872.95) = 872
|
floor(872.95) = 872
|
||||||
```
|
```
|
||||||
|
|
||||||
The displayed-source calculation misses by one because `753` is already
|
The calculation misses by one because `753` is truncated. The ratio applied to
|
||||||
truncated. Applying the ratio to the unrounded source value produces the
|
the unrounded source value produces `873`, so calculations require stored
|
||||||
observed `873`. This is why calculations must use stored decimal endpoints.
|
decimal endpoints.
|
||||||
|
|
||||||
The observed changes over those ten levels were linear:
|
The observed changes over those ten levels were linear:
|
||||||
|
|
||||||
@@ -207,11 +204,10 @@ ENG: 45 → 52 (about 0.7 per level)
|
|||||||
|
|
||||||
### Early weapon levels
|
### Early weapon levels
|
||||||
|
|
||||||
Do not apply the provisional late-level weapon offset to level 1. An Exotic
|
Do not apply the provisional late-level weapon offset at level 1. An Exotic
|
||||||
sample's displayed level-1 values and level-50 values fit a `2.91` endpoint
|
sample's level-1 and level-50 displays fit a `2.91` endpoint ratio for most
|
||||||
ratio for most stats, which is incompatible with simply extending the
|
stats, which conflicts with extending the level-40-to-level-50 offset
|
||||||
level-40-to-level-50 offset backward. Weapons therefore also appear to have an
|
backward. Weapons appear to have an early transition or quality-dependent input.
|
||||||
early transition or another quality-dependent input.
|
|
||||||
|
|
||||||
Until more intermediate samples are available:
|
Until more intermediate samples are available:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Talent Mechanic Rules
|
||||||
|
|
||||||
|
The project owner confirmed these game rules on July 29, 2026. Apply them when
|
||||||
|
importing Talents, editing mechanic programs, evaluating combat events, or
|
||||||
|
deciding whether a Talent still requires assumptions.
|
||||||
|
|
||||||
|
## Shared timed-stack lifecycle
|
||||||
|
|
||||||
|
Timed stack pools use one shared duration:
|
||||||
|
|
||||||
|
- Gaining a stack refreshes the duration for the full pool.
|
||||||
|
- The pool loses all stacks when the duration expires.
|
||||||
|
- A qualifying activation at maximum stacks refreshes the duration. The stack
|
||||||
|
count remains at its maximum.
|
||||||
|
|
||||||
|
This rule covers Combat Inspiration, Simple Pleasures, Explosive Fury, Injured
|
||||||
|
Taunt, Defense Enhancement, Explosive Burst, Unstoppable, Painkillers, Injured
|
||||||
|
Concealment, Hypervigilant, Suppressive Fire, Defensive Charge, and Battlelust.
|
||||||
|
|
||||||
|
## Trigger definitions
|
||||||
|
|
||||||
|
Use these event boundaries:
|
||||||
|
|
||||||
|
- **Hit taken:** Count any hit, including a hit absorbed by Extra Health.
|
||||||
|
Friendly Skill healing also counts as a hit.
|
||||||
|
- **Healing:** Count each healing event. A heal-over-time effect counts each
|
||||||
|
tick as a separate event.
|
||||||
|
- **Extra Health gained:** Count every Extra Health grant, including a grant
|
||||||
|
received while the Agent already has Extra Health. Use `extra_health_granted`
|
||||||
|
for the grant action.
|
||||||
|
- **Extra Health state changed:** `extra_health_changed` is Agent-scoped. Emit
|
||||||
|
it when the Agent receives a grant, when the remaining amount changes, and
|
||||||
|
when it is depleted to zero. Every event requires the boolean
|
||||||
|
`values.hasExtraHealth`. It may include the non-negative
|
||||||
|
`values.currentExtraHealth`, which must agree with that status. This state
|
||||||
|
event is distinct from the `extra_health_granted` grant action.
|
||||||
|
- **Reloading:** Trigger on reload completion when the weapon had at least one
|
||||||
|
round to load. A canceled reload does not count. A weapon that loads one round
|
||||||
|
at a time produces one completed-reload event for each round.
|
||||||
|
- **Taking damage:** Require Health loss. A hit absorbed by Extra Health does
|
||||||
|
not count as taking damage.
|
||||||
|
|
||||||
|
## Talent cooldowns
|
||||||
|
|
||||||
|
A Talent starts its cooldown when it activates. A failed chance roll does not
|
||||||
|
start the cooldown.
|
||||||
|
|
||||||
|
Battlelust can grant no more than one stack per second because a successful
|
||||||
|
activation starts its one-second cooldown. Its five-second duration and
|
||||||
|
three-stack maximum still allow the Agent to reach three stacks. A successful
|
||||||
|
activation at maximum stacks refreshes the five-second duration.
|
||||||
|
|
||||||
|
Apply the same activation-only cooldown rule to Explosive Fury and Explosive
|
||||||
|
Burst. A trigger that occurs during cooldown cannot activate the Talent.
|
||||||
|
|
||||||
|
## Persistent deployed Skills
|
||||||
|
|
||||||
|
Inspiration and Crossfire use a binary deployed-Skill state:
|
||||||
|
|
||||||
|
- The benefit remains active while the Agent has at least one persistent
|
||||||
|
deployed Skill object.
|
||||||
|
- Destruction or timed expiration removes that object from the active count. The
|
||||||
|
benefit ends when no qualifying objects remain.
|
||||||
|
- Instant-use Skills do not qualify.
|
||||||
|
- Multiple deployed Skill objects do not multiply the benefit.
|
||||||
|
|
||||||
|
Ability data must identify whether an ability leaves a persistent deployed
|
||||||
|
object. Use that flag instead of inferring deployment behavior from an ability
|
||||||
|
name or category.
|
||||||
|
|
||||||
|
## Downed allies
|
||||||
|
|
||||||
|
Suppression Rescue and Instant Triage activate while a downed ally is within 30
|
||||||
|
meters:
|
||||||
|
|
||||||
|
- Moving within 30 meters of an ally who is already downed qualifies.
|
||||||
|
- Each qualifying presence refreshes the ten-second duration.
|
||||||
|
- Leaving the radius or reviving the ally does not cancel the current ten-second
|
||||||
|
duration.
|
||||||
|
- The wearer does not count as an ally.
|
||||||
|
|
||||||
|
## Close and Personal
|
||||||
|
|
||||||
|
Close and Personal uses a proximity-snapshot qualifying set:
|
||||||
|
|
||||||
|
- Include each distinct allied Agent whose distance from the source Agent is
|
||||||
|
less than or equal to 30 meters and who has at least one buff granted by that
|
||||||
|
source Agent.
|
||||||
|
- Count an ally once when several of the source Agent's buffs qualify.
|
||||||
|
- Do not include an ally whose qualifying buffs come only from other sources.
|
||||||
|
|
||||||
|
Record the resolved non-negative integer count in
|
||||||
|
`proximity_snapshot.values.alliesWithin30MetersWithAgentGrantedBuff`. The
|
||||||
|
calculation builder supplies it from the
|
||||||
|
`allies-within-30m-with-agent-granted-buff` scenario input. The confirmed rule
|
||||||
|
defines this qualifying set but does not provide Close and Personal's base
|
||||||
|
percentage. Keep that percentage unresolved until source evidence or an author's
|
||||||
|
clarification supplies it.
|
||||||
|
|
||||||
|
## Emergency Retreat
|
||||||
|
|
||||||
|
Emergency Retreat activates whenever at least one enemy is within 20 meters and
|
||||||
|
the Talent is off cooldown. The same enemy can activate it again when the
|
||||||
|
cooldown ends. Each activation lasts three seconds from its activation time.
|
||||||
|
Leaving the radius does not end that active three-second effect.
|
||||||
|
|
||||||
|
## Second Wind
|
||||||
|
|
||||||
|
Second Wind follows the shared reload-completion rule. Any completed reload
|
||||||
|
qualifies when the weapon had at least one round to load. This includes each
|
||||||
|
round inserted by a weapon that reloads one round at a time. A canceled reload
|
||||||
|
does not qualify.
|
||||||
|
|
||||||
|
Resolve the Extra Health amount from the Agent's displayed Firepower when Second
|
||||||
|
Wind activates. Include all equipped-item and active-buff contributions in that
|
||||||
|
Firepower value:
|
||||||
|
|
||||||
|
- Tier I grants Extra Health equal to 600% of displayed Firepower.
|
||||||
|
- Tier II grants Extra Health equal to 840% of displayed Firepower.
|
||||||
|
|
||||||
|
Second Wind adds its Extra Health to Extra Health from other sources. Its
|
||||||
|
five-second duration and five-second cooldown start when it activates. Expiry
|
||||||
|
removes only the Second Wind contribution. At the five-second boundary, the
|
||||||
|
prior grant can expire as a qualifying reload completion grants a new amount. If
|
||||||
|
Second Wind supplies the Agent's only Extra Health, the old amount expires and
|
||||||
|
the new amount begins at that boundary.
|
||||||
|
|
||||||
|
## Nothing to Lose
|
||||||
|
|
||||||
|
Nothing to Lose uses a forward-looking deployment window:
|
||||||
|
|
||||||
|
- Each Health-damage instance makes its own 50% activation roll. Damage absorbed
|
||||||
|
by Extra Health does not roll.
|
||||||
|
- A successful activation applies to every Skill deployed during the five
|
||||||
|
seconds after that damage instance. Several Skills can qualify during the same
|
||||||
|
window.
|
||||||
|
- Calculate each reduction from the Skill's total cooldown after equipment
|
||||||
|
modifiers.
|
||||||
|
- A Skill can receive no more than three reductions during one cooldown cycle.
|
||||||
|
|
||||||
|
## OS Protocol Talents
|
||||||
|
|
||||||
|
Happy Survival restores Health once per second while it has a qualifying nearby
|
||||||
|
enemy count. Each restoration resolves 0.5% of maximum Health per enemy within
|
||||||
|
10 meters, capped at 2.5% before OS Protocol upgrade scaling.
|
||||||
|
|
||||||
|
Explosive Charge requires three seconds of uninterrupted firing. Completion
|
||||||
|
opens an eight-second window to deploy the next Skill. The window ends when the
|
||||||
|
Agent deploys that Skill. Only the enhanced Skill's first damaging hit creates
|
||||||
|
the five-meter explosion. The shared Talent cooldown rule starts its five-second
|
||||||
|
cooldown when that explosion activates.
|
||||||
|
|
||||||
|
Heavy Armor and Pummeling Shield replace and refresh their own active Extra
|
||||||
|
Health grant when the same Talent activates again. Extra Health grants from
|
||||||
|
different sources remain separate.
|
||||||
|
|
||||||
|
Pummeling Shield counts confirmed weapon hits while the Agent possesses Extra
|
||||||
|
Health. Losing all Extra Health resets the hit count. The full-Health condition
|
||||||
|
checks Health immediately before the incoming hit resolves.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Domain docs
|
||||||
|
|
||||||
|
Engineering skills must consult this repository's domain documentation before
|
||||||
|
exploring code.
|
||||||
|
|
||||||
|
## Read before exploring
|
||||||
|
|
||||||
|
- Read `CONTEXT.md` at the repository root.
|
||||||
|
- If `CONTEXT-MAP.md` exists, use it to find the `CONTEXT.md` files relevant to
|
||||||
|
the task.
|
||||||
|
- Read ADRs under `docs/adr/` that affect the area you will change. In a
|
||||||
|
multi-context repository, also check `src/<context>/docs/adr/`.
|
||||||
|
|
||||||
|
If these files do not exist, proceed without comment. Do not suggest creating
|
||||||
|
them before the project resolves domain terms or architectural decisions. The
|
||||||
|
`/domain-modeling` skill, reached through `/grill-with-docs` and
|
||||||
|
`/improve-codebase-architecture`, creates them when needed.
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
This repository uses the single-context layout:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── CONTEXT.md
|
||||||
|
├── docs/adr/
|
||||||
|
│ ├── 0001-event-sourced-orders.md
|
||||||
|
│ └── 0002-postgres-for-write-model.md
|
||||||
|
└── src/
|
||||||
|
```
|
||||||
|
|
||||||
|
A repository with multiple bounded contexts can use this layout:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── CONTEXT-MAP.md
|
||||||
|
├── docs/adr/
|
||||||
|
└── src/
|
||||||
|
├── ordering/
|
||||||
|
│ ├── CONTEXT.md
|
||||||
|
│ └── docs/adr/
|
||||||
|
└── billing/
|
||||||
|
├── CONTEXT.md
|
||||||
|
└── docs/adr/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use glossary terms
|
||||||
|
|
||||||
|
Use the terms defined in `CONTEXT.md` in issue titles, refactor proposals,
|
||||||
|
hypotheses, and test names. Do not substitute synonyms that the glossary
|
||||||
|
rejects.
|
||||||
|
|
||||||
|
If a needed concept is absent, reconsider whether the project uses that concept.
|
||||||
|
If it represents a real gap, record it for `/domain-modeling`.
|
||||||
|
|
||||||
|
## Flag ADR conflicts
|
||||||
|
|
||||||
|
Call out any proposal that conflicts with an ADR:
|
||||||
|
|
||||||
|
> _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Issue tracker: Gitea
|
||||||
|
|
||||||
|
Issues and specs for this repo live as Gitea issues. Use the
|
||||||
|
[`tea`](https://gitea.com/gitea/tea) CLI for all operations.
|
||||||
|
|
||||||
|
## Repository context
|
||||||
|
|
||||||
|
Run commands inside the clone so `tea` can infer the repository and login from
|
||||||
|
its Git remote. If the clone has multiple Gitea remotes or logins, pass
|
||||||
|
`--remote <name>` or `--login <name>`. Use `--repo <owner>/<repo>` only outside
|
||||||
|
the clone.
|
||||||
|
|
||||||
|
Before the first operation, verify the instance with
|
||||||
|
`tea logins list --output json`. If it is missing, ask the user to run
|
||||||
|
`tea login add`; never ask for or handle their access token.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Create an issue**: `tea issues create --title "..." --description "..."`.
|
||||||
|
For a multi-line body, use shell command substitution around a quoted heredoc:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea issues create --title "..." --description "$(cat <<'EOF'
|
||||||
|
Multi-line issue body.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Read an issue with comments**:
|
||||||
|
`tea issues <number> --comments --fields index,state,author,url,title,body,assignees,labels,comments --output json`.
|
||||||
|
- **List issues**:
|
||||||
|
`tea issues list --state open --fields index,state,author,url,title,body,assignees,labels,comments --output json`,
|
||||||
|
adding `--labels "label-one,label-two"`, `--assignee <user>`, or another
|
||||||
|
supported filter as needed. Paginate with `--page` and `--limit`; do not
|
||||||
|
assume the default first 30 results are complete.
|
||||||
|
- **Comment on an issue**: `tea comments add <number> --description "..."`.
|
||||||
|
- **Apply or remove labels**: `tea issues edit <number> --add-labels "..."` or
|
||||||
|
`--remove-labels "..."`. Separate multiple labels with commas.
|
||||||
|
- **Assign or unassign**: `tea issues edit <number> --add-assignees "..."` or
|
||||||
|
`--remove-assignees "..."`.
|
||||||
|
- **Close**: post the closing explanation with
|
||||||
|
`tea comments add <number> --description "..."`, then run
|
||||||
|
`tea issues close <number>`.
|
||||||
|
- **Reopen**: `tea issues reopen <number>`.
|
||||||
|
- **Raw API fallback**: use `tea api '<endpoint>'` only for a Gitea feature
|
||||||
|
without a first-class `tea` command. Prefer `{owner}` and `{repo}`
|
||||||
|
placeholders so repository context remains explicit.
|
||||||
|
|
||||||
|
Use `--output json` for machine-readable reads. Do not scrape table output.
|
||||||
|
|
||||||
|
## Pull requests as a triage surface
|
||||||
|
|
||||||
|
**PRs as a request surface: no.** _(Set this to `yes` if this repo treats
|
||||||
|
external pull requests as feature requests; `/triage` reads this flag.)_
|
||||||
|
|
||||||
|
When set to `yes`, PRs use the same labels and states as issues:
|
||||||
|
|
||||||
|
- **Read a PR with comments and diff**:
|
||||||
|
`tea pulls <number> --comments --fields index,state,author,url,title,body,diff,assignees,labels,comments --output json`.
|
||||||
|
- **List open PRs**:
|
||||||
|
`tea pulls list --state open --fields index,state,author,author-id,url,title,body,assignees,labels,comments --output json`.
|
||||||
|
Determine whether an author is external from the repository's Gitea membership
|
||||||
|
data; the PR listing does not classify author membership.
|
||||||
|
- **Comment**: `tea comments add <number> --description "..."`.
|
||||||
|
- **Apply or remove labels**: `tea pulls edit <number> --add-labels "..."` or
|
||||||
|
`--remove-labels "..."`.
|
||||||
|
- **Close**: `tea pulls close <number>`.
|
||||||
|
|
||||||
|
Gitea pull requests use the repository's issue index space, so `#42` may refer
|
||||||
|
to either surface. If the context is unclear, inspect it with
|
||||||
|
`tea pulls 42 --output json`; if that command reports no PR, read it with
|
||||||
|
`tea issues 42 --output json`.
|
||||||
|
|
||||||
|
## When a skill says "publish to the issue tracker"
|
||||||
|
|
||||||
|
Create a Gitea issue with `tea issues create`.
|
||||||
|
|
||||||
|
## When a skill says "fetch the relevant ticket"
|
||||||
|
|
||||||
|
Run
|
||||||
|
`tea issues <number> --comments --fields index,state,author,url,title,body,assignees,labels,comments --output json`.
|
||||||
|
|
||||||
|
## Wayfinding operations
|
||||||
|
|
||||||
|
`/wayfinder` uses one **map** issue with linked **child** issues. These
|
||||||
|
conventions store relationships in issue bodies, so they work across Gitea
|
||||||
|
versions.
|
||||||
|
|
||||||
|
- **Map**: create one issue labelled `wayfinder:map` containing the Notes,
|
||||||
|
Decisions-so-far, and Fog sections. Use
|
||||||
|
`tea issues create --labels "wayfinder:map"` with its title and description.
|
||||||
|
- **Child ticket**: start the body with `Part of #<map>` and apply a
|
||||||
|
`wayfinder:<type>` label: `research`, `prototype`, `grilling`, or `task`. Add
|
||||||
|
each child as a task-list item in the map body to preserve frontier order.
|
||||||
|
- **Blocking**: put `Blocked by: #<n>, #<n>` near the top of the child body. A
|
||||||
|
ticket becomes unblocked after every referenced blocker closes.
|
||||||
|
- **Frontier query**: list all open children, order them by the map's task list,
|
||||||
|
and discard tickets with an open blocker or an assignee. Select the first
|
||||||
|
remaining ticket. Fetch each referenced blocker; do not infer its state from
|
||||||
|
map text.
|
||||||
|
- **Claim**: resolve the active username with `tea whoami`, then run
|
||||||
|
`tea issues edit <n> --add-assignees "<username>"` as the session's first
|
||||||
|
write. Do not assume `@me` works.
|
||||||
|
- **Resolve**: run `tea comments add <n> --description "<answer>"`, close the
|
||||||
|
child with `tea issues close <n>`, then append a context pointer and link to
|
||||||
|
the map's Decisions-so-far section.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Triage labels
|
||||||
|
|
||||||
|
Engineering skills use five canonical triage roles. This table maps each role to
|
||||||
|
the label string in this repository's issue tracker.
|
||||||
|
|
||||||
|
| Canonical role | Tracker label | Meaning |
|
||||||
|
| ----------------- | ----------------- | -------------------------------------- |
|
||||||
|
| `needs-triage` | `needs-triage` | A maintainer must evaluate the issue |
|
||||||
|
| `needs-info` | `needs-info` | Waiting for more information |
|
||||||
|
| `ready-for-agent` | `ready-for-agent` | Fully specified and ready for an agent |
|
||||||
|
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
||||||
|
| `wontfix` | `wontfix` | Maintainers will not act on the issue |
|
||||||
|
|
||||||
|
When a skill names a canonical role, use the corresponding tracker label.
|
||||||
+974
-209
File diff suppressed because it is too large
Load Diff
Generated
+4602
-2604
File diff suppressed because it is too large
Load Diff
+82
-55
@@ -1,57 +1,84 @@
|
|||||||
{
|
{
|
||||||
"name": "shd-build",
|
"name": "shd-build",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "dotenvx run -f .env.development -- vite dev --host 0.0.0.0",
|
"dev": "infisical run --env=dev --path=/app --path=/shared/scheduler -- vite dev --host 0.0.0.0",
|
||||||
"build": "dotenvx run -f .env.production -- vite build",
|
"build:app": "vite build",
|
||||||
"preview": "vite preview",
|
"build:staging": "vite build",
|
||||||
"db:migrate": "dotenvx run -f .env.development -- node scripts/db-migrate.mjs",
|
"build": "vite build",
|
||||||
"db:migrate:production": "dotenvx run -f .env.production -- node scripts/db-migrate.mjs",
|
"brand:render": "node scripts/render-brand-assets.mjs",
|
||||||
"db:seed-users": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs",
|
"start": "node build",
|
||||||
"db:seed-users:reset": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs --reset-passwords",
|
"preview": "node build",
|
||||||
"db:backup": "dotenvx run -f .env.development -- node scripts/db-backup.mjs",
|
"db:migrate": "infisical run --env=dev --path=/app --path=/shared/scheduler -- node scripts/db-migrate.mjs",
|
||||||
"db:backup:production": "dotenvx run -f .env.production -- node scripts/db-backup.mjs",
|
"db:migrate:staging": "infisical run --env=staging --path=/app --path=/shared/scheduler -- node scripts/db-migrate.mjs",
|
||||||
"db:restore": "dotenvx run -f .env.development -- node scripts/db-restore.mjs",
|
"db:migrate:production": "infisical run --env=prod --path=/app --path=/shared/scheduler -- node scripts/db-migrate.mjs",
|
||||||
"db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs",
|
"db:seed-users": "infisical run --env=dev --path=/app -- node scripts/seed-initial-users.mjs",
|
||||||
"notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs",
|
"db:seed-users:reset": "infisical run --env=dev --path=/app -- node scripts/seed-initial-users.mjs --reset-passwords",
|
||||||
"notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs",
|
"db:backup": "infisical run --env=dev --path=/app -- node scripts/db-backup.mjs",
|
||||||
"notifications:oversight": "dotenvx run -f .env.development -- node scripts/oversight-notification-dispatch.mjs",
|
"db:backup:staging": "infisical run --env=staging --path=/app -- node scripts/db-backup.mjs",
|
||||||
"notifications:oversight:production": "dotenvx run -f .env.production -- node scripts/oversight-notification-dispatch.mjs",
|
"db:backup:production": "infisical run --env=prod --path=/app -- node scripts/db-backup.mjs",
|
||||||
"notifications:system": "dotenvx run -f .env.development -- node scripts/system-notification-dispatch.mjs",
|
"db:restore": "infisical run --env=dev --path=/app -- node scripts/db-restore.mjs",
|
||||||
"notifications:system:production": "dotenvx run -f .env.production -- node scripts/system-notification-dispatch.mjs",
|
"db:restore:production": "infisical run --env=prod --path=/app -- node scripts/db-restore.mjs",
|
||||||
"notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs",
|
"article:export": "infisical run --env=dev --path=/app -- node scripts/article-transfer.mjs export",
|
||||||
"assets:trim-icons": "node scripts/trim-static-icons.mjs",
|
"isac:export": "infisical run --env=dev --path=/app -- node scripts/isac-data-export.mjs",
|
||||||
"env:encrypt": "node scripts/env-files.mjs encrypt",
|
"isac:import": "infisical run --env=dev --path=/app -- node scripts/isac-data-import.mjs",
|
||||||
"env:decrypt": "node scripts/env-files.mjs decrypt",
|
"isac:attributes:import": "infisical run --env=dev --path=/app -- node scripts/import-resurgence-attributes.mjs",
|
||||||
"env:encrypt:development": "dotenvx encrypt -f .env.development",
|
"isac:export:staging": "infisical run --env=staging --path=/app -- node scripts/isac-data-export.mjs",
|
||||||
"env:decrypt:development": "dotenvx decrypt -f .env.development",
|
"images:export": "infisical run --env=dev --path=/app -- node scripts/managed-images-export.mjs",
|
||||||
"env:encrypt:production": "dotenvx encrypt -f .env.production",
|
"images:import": "infisical run --env=dev --path=/app -- node scripts/managed-images-import.mjs",
|
||||||
"env:decrypt:production": "dotenvx decrypt -f .env.production",
|
"images:import:staging": "infisical run --env=staging --path=/app -- node scripts/managed-images-import.mjs",
|
||||||
"env:stage": "node scripts/env-files.mjs stage",
|
"images:import:production": "infisical run --env=prod --path=/app -- node scripts/managed-images-import.mjs",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"stage:bootstrap:export": "infisical run --env=dev --path=/app -- node scripts/stage-bootstrap-export.mjs",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"production:bootstrap:export": "infisical run --env=dev --path=/app -- node scripts/environment-bootstrap-export.mjs production",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
"notifications:digest": "infisical run --env=dev --path=/app -- node scripts/notification-digest.mjs",
|
||||||
},
|
"notifications:digest:production": "infisical run --env=prod --path=/app -- node scripts/notification-digest.mjs",
|
||||||
"devDependencies": {
|
"notifications:oversight": "infisical run --env=dev --path=/app -- node scripts/oversight-notification-dispatch.mjs",
|
||||||
"@dotenvx/dotenvx": "^2.17.4",
|
"notifications:oversight:production": "infisical run --env=prod --path=/app -- node scripts/oversight-notification-dispatch.mjs",
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"notifications:system": "infisical run --env=dev --path=/app -- node scripts/system-notification-dispatch.mjs",
|
||||||
"@sveltejs/kit": "^2.63.0",
|
"notifications:system:production": "infisical run --env=prod --path=/app -- node scripts/system-notification-dispatch.mjs",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"notifications:seed-test": "infisical run --env=dev --path=/app -- node scripts/seed-test-notifications.mjs",
|
||||||
"@types/node": "^26.1.1",
|
"assets:trim-icons": "node scripts/trim-static-icons.mjs",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"prepare": "node scripts/prepare.mjs",
|
||||||
"svelte": "^5.56.1",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"svelte-check": "^4.6.0",
|
"test": "vitest run",
|
||||||
"typescript": "^6.0.3",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
"vite": "^8.0.16"
|
},
|
||||||
},
|
"lint-staged": {
|
||||||
"dependencies": {
|
"**/*": "prettier --write --ignore-unknown"
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
},
|
||||||
"@simplewebauthn/server": "^13.3.2",
|
"devDependencies": {
|
||||||
"@tursodatabase/database": "^0.7.1",
|
"@infisical/cli": "0.43.116",
|
||||||
"argon2": "^0.45.1",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"nodemailer": "^9.0.3",
|
"@sveltejs/kit": "^2.63.0",
|
||||||
"sharp": "^0.35.3"
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
}
|
"@types/node": "^26.1.1",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^16.4.0",
|
||||||
|
"opentype.js": "^2.0.0",
|
||||||
|
"prettier": "3.9.6",
|
||||||
|
"svelte": "^5.56.1",
|
||||||
|
"svelte-check": "^4.6.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/barlow": "^5.3.0",
|
||||||
|
"@fontsource/source-serif-4": "^5.3.0",
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
|
"@tiptap/core": "3.29.2",
|
||||||
|
"@tiptap/extension-link": "3.29.2",
|
||||||
|
"@tiptap/extensions": "3.29.2",
|
||||||
|
"@tiptap/pm": "3.29.2",
|
||||||
|
"@tiptap/starter-kit": "3.29.2",
|
||||||
|
"@tursodatabase/database": "^0.7.1",
|
||||||
|
"argon2": "^0.45.1",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
|
"openai": "^6.49.0",
|
||||||
|
"sharp": "^0.35.3"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"worktree": {
|
||||||
|
"setup": "npm install"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import { connect } from '@tursodatabase/database';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { mkdir, readFile, link, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { basename, dirname, join, resolve } from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const mode = process.argv[2];
|
||||||
|
const subject = process.argv[3];
|
||||||
|
const confirmation = '--confirm-article-import';
|
||||||
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
|
);
|
||||||
|
const imageDirectory = resolve(
|
||||||
|
process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons',
|
||||||
|
);
|
||||||
|
const UUID_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
const STORAGE_KEY_PATTERN = /^library-[0-9a-f-]{36}\.webp$/i;
|
||||||
|
|
||||||
|
const failUsage = () => {
|
||||||
|
console.error(
|
||||||
|
'Usage: node scripts/article-transfer.mjs export <article-id-or-slug> [output.json]',
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
` or: ARTICLE_IMPORT_ENABLED=true node scripts/article-transfer.mjs import <bundle.json> ${confirmation}`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!['export', 'import'].includes(mode) || !subject) failUsage();
|
||||||
|
|
||||||
|
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
||||||
|
const quoteIdentifier = (identifier) =>
|
||||||
|
`"${String(identifier).replaceAll('"', '""')}"`;
|
||||||
|
const isRecord = (value) =>
|
||||||
|
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
async function insertRow(database, table, row) {
|
||||||
|
const columns = Object.keys(row);
|
||||||
|
if (!columns.length) throw new Error(`Cannot insert an empty ${table} row.`);
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
`INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(',')}) VALUES (${columns.map(() => '?').join(',')})`,
|
||||||
|
)
|
||||||
|
).run(...columns.map((column) => row[column]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportArticle() {
|
||||||
|
const database = await connect(databasePath);
|
||||||
|
const article = await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT * FROM articles WHERE id=? OR slug=? ORDER BY CASE WHEN id=? THEN 0 ELSE 1 END LIMIT 1`,
|
||||||
|
)
|
||||||
|
).get(subject, subject, subject);
|
||||||
|
if (!article) throw new Error('Article not found.');
|
||||||
|
if (article.kind !== 'news')
|
||||||
|
throw new Error('Only News articles can use this transfer command.');
|
||||||
|
if (article.deleted_at)
|
||||||
|
throw new Error('Deleted News articles cannot be transferred.');
|
||||||
|
const external = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT article_id FROM external_news_sources WHERE article_id=?',
|
||||||
|
)
|
||||||
|
).get(article.id);
|
||||||
|
if (external)
|
||||||
|
throw new Error(
|
||||||
|
'Syndicated News articles must come from their source feed.',
|
||||||
|
);
|
||||||
|
const embeds = await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT COUNT(*) AS count FROM article_revision_build_embeds
|
||||||
|
WHERE revision_id IN (SELECT id FROM article_revisions WHERE article_id=?)`,
|
||||||
|
)
|
||||||
|
).get(article.id);
|
||||||
|
if (Number(embeds?.count ?? 0) > 0)
|
||||||
|
throw new Error('News article transfers cannot contain Build embeds.');
|
||||||
|
|
||||||
|
const owner = await (
|
||||||
|
await database.prepare('SELECT id,username FROM users WHERE id=?')
|
||||||
|
).get(article.owner_id);
|
||||||
|
if (!owner?.username)
|
||||||
|
throw new Error('The article owner does not have a transferable username.');
|
||||||
|
const draft = await (
|
||||||
|
await database.prepare('SELECT * FROM article_drafts WHERE article_id=?')
|
||||||
|
).get(article.id);
|
||||||
|
const revisions = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT * FROM article_revisions WHERE article_id=? ORDER BY revision_number',
|
||||||
|
)
|
||||||
|
).all(article.id);
|
||||||
|
if (!draft || !revisions.length)
|
||||||
|
throw new Error('The article is missing its draft or revision history.');
|
||||||
|
const revisionIds = revisions.map((revision) => String(revision.id));
|
||||||
|
const placeholders = revisionIds.map(() => '?').join(',');
|
||||||
|
const revisionTags = await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT * FROM article_revision_tags WHERE revision_id IN (${placeholders}) ORDER BY revision_id,ordinal`,
|
||||||
|
)
|
||||||
|
).all(...revisionIds);
|
||||||
|
const media = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT * FROM article_media WHERE article_id=? ORDER BY image_id',
|
||||||
|
)
|
||||||
|
).all(article.id);
|
||||||
|
const search = await (
|
||||||
|
await database.prepare('SELECT * FROM article_search WHERE article_id=?')
|
||||||
|
).get(article.id);
|
||||||
|
const imageIds = [...new Set(media.map((row) => String(row.image_id)))];
|
||||||
|
const images = imageIds.length
|
||||||
|
? await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT * FROM managed_images WHERE id IN (${imageIds.map(() => '?').join(',')}) ORDER BY id`,
|
||||||
|
)
|
||||||
|
).all(...imageIds)
|
||||||
|
: [];
|
||||||
|
if (images.length !== imageIds.length)
|
||||||
|
throw new Error('The article references missing managed-image metadata.');
|
||||||
|
const files = [];
|
||||||
|
for (const image of images) {
|
||||||
|
const storageKey = String(image.storage_key);
|
||||||
|
if (!STORAGE_KEY_PATTERN.test(storageKey))
|
||||||
|
throw new Error('The article uses an unsupported managed-image key.');
|
||||||
|
const bytes = await readFile(join(imageDirectory, storageKey));
|
||||||
|
if (bytes.byteLength !== Number(image.byte_size))
|
||||||
|
throw new Error(`Managed image ${image.id} has an invalid byte size.`);
|
||||||
|
files.push({
|
||||||
|
storageKey,
|
||||||
|
byteSize: bytes.byteLength,
|
||||||
|
sha256: hash(bytes),
|
||||||
|
contentBase64: bytes.toString('base64'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle = {
|
||||||
|
format: 'shd-build-article-transfer',
|
||||||
|
version: 1,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
owner: { sourceId: String(owner.id), username: String(owner.username) },
|
||||||
|
article,
|
||||||
|
draft,
|
||||||
|
revisions,
|
||||||
|
revisionTags,
|
||||||
|
media,
|
||||||
|
search: search ?? null,
|
||||||
|
images,
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const outputPath = resolve(
|
||||||
|
process.argv[4] ||
|
||||||
|
`./data/backups/article-${String(article.slug)}-${timestamp}.json`,
|
||||||
|
);
|
||||||
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
|
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
flag: 'wx',
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`Exported News article "${article.slug}" with ${revisions.length} revisions and ${files.length} images.`,
|
||||||
|
);
|
||||||
|
console.log(`Article bundle: ${outputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBundle(bundle) {
|
||||||
|
if (
|
||||||
|
!isRecord(bundle) ||
|
||||||
|
bundle.format !== 'shd-build-article-transfer' ||
|
||||||
|
bundle.version !== 1 ||
|
||||||
|
!isRecord(bundle.owner) ||
|
||||||
|
!isRecord(bundle.article) ||
|
||||||
|
!isRecord(bundle.draft) ||
|
||||||
|
!Array.isArray(bundle.revisions) ||
|
||||||
|
!Array.isArray(bundle.revisionTags) ||
|
||||||
|
!Array.isArray(bundle.media) ||
|
||||||
|
!Array.isArray(bundle.images) ||
|
||||||
|
!Array.isArray(bundle.files) ||
|
||||||
|
bundle.article.kind !== 'news' ||
|
||||||
|
!UUID_PATTERN.test(String(bundle.article.id)) ||
|
||||||
|
!bundle.owner.username ||
|
||||||
|
!bundle.revisions.length
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The selected file is not a supported News article bundle.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const articleId = String(bundle.article.id);
|
||||||
|
if (
|
||||||
|
bundle.draft.article_id !== articleId ||
|
||||||
|
bundle.revisions.some(
|
||||||
|
(revision) =>
|
||||||
|
!isRecord(revision) ||
|
||||||
|
revision.article_id !== articleId ||
|
||||||
|
!UUID_PATTERN.test(String(revision.id)),
|
||||||
|
) ||
|
||||||
|
bundle.media.some(
|
||||||
|
(row) => !isRecord(row) || row.article_id !== articleId,
|
||||||
|
) ||
|
||||||
|
bundle.images.some(
|
||||||
|
(image) =>
|
||||||
|
!isRecord(image) ||
|
||||||
|
!UUID_PATTERN.test(String(image.id)) ||
|
||||||
|
!STORAGE_KEY_PATTERN.test(String(image.storage_key)) ||
|
||||||
|
image.source_entity_type !== null ||
|
||||||
|
image.source_entity_id !== null,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error('The News article bundle contains inconsistent records.');
|
||||||
|
}
|
||||||
|
const imageIds = new Set(bundle.images.map((image) => String(image.id)));
|
||||||
|
const files = new Map();
|
||||||
|
for (const file of bundle.files) {
|
||||||
|
if (
|
||||||
|
!isRecord(file) ||
|
||||||
|
!STORAGE_KEY_PATTERN.test(String(file.storageKey)) ||
|
||||||
|
!Number.isInteger(file.byteSize) ||
|
||||||
|
file.byteSize <= 0 ||
|
||||||
|
typeof file.sha256 !== 'string' ||
|
||||||
|
!/^[0-9a-f]{64}$/.test(file.sha256) ||
|
||||||
|
typeof file.contentBase64 !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The News article bundle contains an invalid image file.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const bytes = Buffer.from(file.contentBase64, 'base64');
|
||||||
|
if (bytes.byteLength !== file.byteSize || hash(bytes) !== file.sha256)
|
||||||
|
throw new Error('The News article bundle contains a corrupt image file.');
|
||||||
|
files.set(String(file.storageKey), bytes);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
bundle.images.some((image) => !files.has(String(image.storage_key))) ||
|
||||||
|
bundle.media.some((row) => !imageIds.has(String(row.image_id)))
|
||||||
|
) {
|
||||||
|
throw new Error('The News article bundle is missing image dependencies.');
|
||||||
|
}
|
||||||
|
const revisionIds = new Set(
|
||||||
|
bundle.revisions.map((revision) => String(revision.id)),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!revisionIds.has(String(bundle.article.published_revision_id)) ||
|
||||||
|
(bundle.article.pending_revision_id &&
|
||||||
|
!revisionIds.has(String(bundle.article.pending_revision_id))) ||
|
||||||
|
bundle.revisionTags.some((row) => !revisionIds.has(String(row.revision_id)))
|
||||||
|
) {
|
||||||
|
throw new Error('The News article bundle has invalid revision pointers.');
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importArticle() {
|
||||||
|
if (
|
||||||
|
process.env.ARTICLE_IMPORT_ENABLED !== 'true' ||
|
||||||
|
!process.argv.includes(confirmation)
|
||||||
|
) {
|
||||||
|
failUsage();
|
||||||
|
}
|
||||||
|
const bundlePath = resolve(subject);
|
||||||
|
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
||||||
|
const files = validateBundle(bundle);
|
||||||
|
|
||||||
|
const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
if (safetyBackup.error) throw safetyBackup.error;
|
||||||
|
if (safetyBackup.status !== 0)
|
||||||
|
throw new Error('Article import stopped because its safety backup failed.');
|
||||||
|
|
||||||
|
const database = await connect(databasePath);
|
||||||
|
const existing = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT id,slug FROM articles WHERE id=? OR (kind=? AND slug=?)',
|
||||||
|
)
|
||||||
|
).get(bundle.article.id, bundle.article.kind, bundle.article.slug);
|
||||||
|
if (existing) {
|
||||||
|
if (
|
||||||
|
existing.id === bundle.article.id &&
|
||||||
|
existing.slug === bundle.article.slug
|
||||||
|
) {
|
||||||
|
console.log(`News article "${existing.slug}" is already present.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('The target already contains a conflicting News article.');
|
||||||
|
}
|
||||||
|
const owner = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT id,username FROM users WHERE id=? OR lower(username)=lower(?) ORDER BY CASE WHEN id=? THEN 0 ELSE 1 END LIMIT 1',
|
||||||
|
)
|
||||||
|
).get(bundle.owner.sourceId, bundle.owner.username, bundle.owner.sourceId);
|
||||||
|
if (!owner)
|
||||||
|
throw new Error(
|
||||||
|
`The target does not contain Agent "${bundle.owner.username}".`,
|
||||||
|
);
|
||||||
|
const tagIds = [
|
||||||
|
...new Set(bundle.revisionTags.map((row) => String(row.tag_id))),
|
||||||
|
];
|
||||||
|
if (tagIds.length) {
|
||||||
|
const available = await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT COUNT(*) AS count FROM article_tags WHERE id IN (${tagIds.map(() => '?').join(',')})`,
|
||||||
|
)
|
||||||
|
).get(...tagIds);
|
||||||
|
if (Number(available?.count ?? 0) !== tagIds.length)
|
||||||
|
throw new Error('The target is missing one or more article tags.');
|
||||||
|
}
|
||||||
|
const imageIds = bundle.images.map((image) => String(image.id));
|
||||||
|
if (imageIds.length) {
|
||||||
|
const collision = await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT id FROM managed_images WHERE id IN (${imageIds.map(() => '?').join(',')}) OR storage_key IN (${imageIds.map(() => '?').join(',')}) LIMIT 1`,
|
||||||
|
)
|
||||||
|
).get(...imageIds, ...bundle.images.map((image) => image.storage_key));
|
||||||
|
if (collision)
|
||||||
|
throw new Error('The target contains a conflicting managed image.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(imageDirectory, { recursive: true });
|
||||||
|
const createdFiles = [];
|
||||||
|
let transactionStarted = false;
|
||||||
|
try {
|
||||||
|
for (const image of bundle.images) {
|
||||||
|
const storageKey = String(image.storage_key);
|
||||||
|
const temporaryPath = join(
|
||||||
|
imageDirectory,
|
||||||
|
`.${storageKey}.${randomUUID()}.tmp`,
|
||||||
|
);
|
||||||
|
await writeFile(temporaryPath, files.get(storageKey), {
|
||||||
|
flag: 'wx',
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
const finalPath = join(imageDirectory, storageKey);
|
||||||
|
try {
|
||||||
|
await link(temporaryPath, finalPath);
|
||||||
|
createdFiles.push(finalPath);
|
||||||
|
} finally {
|
||||||
|
await rm(temporaryPath, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await database.exec('BEGIN IMMEDIATE');
|
||||||
|
transactionStarted = true;
|
||||||
|
for (const image of bundle.images) {
|
||||||
|
await insertRow(database, 'managed_images', {
|
||||||
|
...image,
|
||||||
|
created_by: owner.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await insertRow(database, 'articles', {
|
||||||
|
...bundle.article,
|
||||||
|
owner_id: owner.id,
|
||||||
|
visibility: 'private',
|
||||||
|
published_revision_id: null,
|
||||||
|
pending_revision_id: null,
|
||||||
|
});
|
||||||
|
await insertRow(database, 'article_drafts', {
|
||||||
|
...bundle.draft,
|
||||||
|
updated_by: owner.id,
|
||||||
|
});
|
||||||
|
for (const revision of bundle.revisions) {
|
||||||
|
await insertRow(database, 'article_revisions', {
|
||||||
|
...revision,
|
||||||
|
created_by: owner.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const row of bundle.revisionTags)
|
||||||
|
await insertRow(database, 'article_revision_tags', row);
|
||||||
|
for (const row of bundle.media) {
|
||||||
|
await insertRow(database, 'article_media', {
|
||||||
|
...row,
|
||||||
|
uploaded_by: owner.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (bundle.search)
|
||||||
|
await insertRow(database, 'article_search', bundle.search);
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
`UPDATE articles SET visibility=?,published_revision_id=?,pending_revision_id=? WHERE id=?`,
|
||||||
|
)
|
||||||
|
).run(
|
||||||
|
bundle.article.visibility,
|
||||||
|
bundle.article.published_revision_id,
|
||||||
|
bundle.article.pending_revision_id,
|
||||||
|
bundle.article.id,
|
||||||
|
);
|
||||||
|
await insertRow(database, 'audit_log', {
|
||||||
|
id: randomUUID(),
|
||||||
|
actor_id: owner.id,
|
||||||
|
entity_type: 'editorial_content',
|
||||||
|
entity_id: bundle.article.id,
|
||||||
|
action: 'news.promoted',
|
||||||
|
before_json: null,
|
||||||
|
after_json: JSON.stringify({
|
||||||
|
sourceBundle: basename(bundlePath),
|
||||||
|
slug: bundle.article.slug,
|
||||||
|
publishedRevisionId: bundle.article.published_revision_id,
|
||||||
|
revisions: bundle.revisions.length,
|
||||||
|
images: bundle.images.length,
|
||||||
|
}),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
await database.exec('COMMIT');
|
||||||
|
transactionStarted = false;
|
||||||
|
} catch (error) {
|
||||||
|
if (transactionStarted) {
|
||||||
|
try {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
} catch {
|
||||||
|
// Preserve the import error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const file of createdFiles) await rm(file, { force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`Imported News article "${bundle.article.slug}" with ${bundle.revisions.length} revisions and ${bundle.images.length} images for ${owner.username}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'export') await exportArticle();
|
||||||
|
else await importArticle();
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Weapon Critical Hit Chance",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [2.4, 6]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Weapon Critical Hit Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [4, 10],
|
||||||
|
"weaponRange": [8, 20]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Weapon Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [2, 5],
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Weapon Multi-Shot Chance",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [2, 5],
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Critical Chance",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [2.4, 6]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Critical Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [4, 10],
|
||||||
|
"weaponRange": [8, 20]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Intensity",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [2, 5],
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Multi-Shot Chance",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [2, 5],
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Headshot Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [4.8, 12],
|
||||||
|
"weaponRange": [9.6, 24]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Max Health",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Defensive",
|
||||||
|
"category": "Character",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [4, 10],
|
||||||
|
"weaponRange": [8, 20]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Armor",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Defensive",
|
||||||
|
"category": "Character",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [4.66, 11.66],
|
||||||
|
"weaponRange": [9.33, 23.33]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Damage Reduction",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Defensive",
|
||||||
|
"category": "Character",
|
||||||
|
"calculationType": "Multiplicative",
|
||||||
|
"gearRange": [2, 5],
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Received Healing",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Defensive",
|
||||||
|
"category": "Character",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [4.8, 12],
|
||||||
|
"weaponRange": [9.6, 24]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Damage to Elites",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Character",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [2.4, 6],
|
||||||
|
"weaponRange": [4.8, 12]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Signature Ability Charge Efficiency",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Duration",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [3.2, 8]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Radius",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Cooldown Recovery",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [-5, -2]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Weapon Blast Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Weapon Piercing Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Weapon Shredding Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Blast Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Piercing Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Shredding Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rate of Fire",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Reload Speed",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Health",
|
||||||
|
"unit": "",
|
||||||
|
"type": "Defensive",
|
||||||
|
"category": "Character",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Armor",
|
||||||
|
"unit": "",
|
||||||
|
"type": "Defensive",
|
||||||
|
"category": "Character",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Movement Speed",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Defensive",
|
||||||
|
"category": "Character",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Piercing Damage",
|
||||||
|
"unit": "",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RPM",
|
||||||
|
"unit": "Shots",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Optimal Range",
|
||||||
|
"unit": "m",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ADS Speed",
|
||||||
|
"unit": "s",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Reload Speed",
|
||||||
|
"unit": "s",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Magazine Size",
|
||||||
|
"unit": "Shots",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Handling Speed",
|
||||||
|
"unit": "s",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accuracy",
|
||||||
|
"unit": "",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vertical Stability",
|
||||||
|
"unit": "",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
}
|
||||||
|
]
|
||||||
+25
-17
@@ -3,19 +3,25 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|||||||
import { basename, dirname, resolve } from 'node:path';
|
import { basename, dirname, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
|
||||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
|
);
|
||||||
const requestedOutput = process.argv[2];
|
const requestedOutput = process.argv[2];
|
||||||
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
const databaseName = basename(databasePath).replace(/\.[^.]+$/, '');
|
const databaseName = basename(databasePath).replace(/\.[^.]+$/, '');
|
||||||
const outputPath = resolve(
|
const outputPath = resolve(
|
||||||
requestedOutput || `./data/backups/${databaseName}-${timestamp}.json`
|
requestedOutput || `./data/backups/${databaseName}-${timestamp}.json`,
|
||||||
);
|
);
|
||||||
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
|
const quoteIdentifier = (identifier) =>
|
||||||
|
`"${String(identifier).replaceAll('"', '""')}"`;
|
||||||
|
|
||||||
await mkdir(dirname(outputPath), { recursive: true });
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
const database = await connect(databasePath);
|
const database = await connect(databasePath);
|
||||||
const tableRows = await (
|
const tableRows = await (
|
||||||
await database.prepare(`
|
await database.prepare(`
|
||||||
SELECT name
|
SELECT name
|
||||||
FROM sqlite_master
|
FROM sqlite_master
|
||||||
WHERE type = 'table'
|
WHERE type = 'table'
|
||||||
@@ -27,23 +33,25 @@ const tableRows = await (
|
|||||||
const tables = [];
|
const tables = [];
|
||||||
let totalRows = 0;
|
let totalRows = 0;
|
||||||
for (const tableRow of tableRows) {
|
for (const tableRow of tableRows) {
|
||||||
const name = String(tableRow.name);
|
const name = String(tableRow.name);
|
||||||
const rows = await (await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)).all();
|
const rows = await (
|
||||||
tables.push({ name, rowCount: rows.length, rows });
|
await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)
|
||||||
totalRows += rows.length;
|
).all();
|
||||||
|
tables.push({ name, rowCount: rows.length, rows });
|
||||||
|
totalRows += rows.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const backup = {
|
const backup = {
|
||||||
format: 'shd-build-database-backup',
|
format: 'shd-build-database-backup',
|
||||||
version: 1,
|
version: 1,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
sourceDatabase: basename(databasePath),
|
sourceDatabase: basename(databasePath),
|
||||||
totalRows,
|
totalRows,
|
||||||
tables
|
tables,
|
||||||
};
|
};
|
||||||
await writeFile(outputPath, `${JSON.stringify(backup, null, 2)}\n`, {
|
await writeFile(outputPath, `${JSON.stringify(backup, null, 2)}\n`, {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
flag: 'wx'
|
flag: 'wx',
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Backed up ${totalRows} rows from ${tables.length} tables.`);
|
console.log(`Backed up ${totalRows} rows from ${tables.length} tables.`);
|
||||||
|
|||||||
+3705
-158
File diff suppressed because it is too large
Load Diff
+111
-30
@@ -5,53 +5,134 @@ import process from 'node:process';
|
|||||||
|
|
||||||
const backupArgument = process.argv[2];
|
const backupArgument = process.argv[2];
|
||||||
if (!backupArgument) {
|
if (!backupArgument) {
|
||||||
console.error('Usage: npm run db:restore -- <backup-file>');
|
console.error('Usage: npm run db:restore -- <backup-file>');
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const backupPath = resolve(backupArgument);
|
const backupPath = resolve(backupArgument);
|
||||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
|
);
|
||||||
const backup = JSON.parse(await readFile(backupPath, 'utf8'));
|
const backup = JSON.parse(await readFile(backupPath, 'utf8'));
|
||||||
if (backup?.format !== 'shd-build-database-backup' || backup?.version !== 1 || !Array.isArray(backup.tables)) {
|
if (
|
||||||
throw new Error('The selected file is not a supported SHD.build database backup.');
|
backup?.format !== 'shd-build-database-backup' ||
|
||||||
|
backup?.version !== 1 ||
|
||||||
|
!Array.isArray(backup.tables)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The selected file is not a supported SHD.build database backup.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
|
const quoteIdentifier = (identifier) =>
|
||||||
|
`"${String(identifier).replaceAll('"', '""')}"`;
|
||||||
const database = await connect(databasePath);
|
const database = await connect(databasePath);
|
||||||
|
|
||||||
// Restores are deliberately limited to empty databases. This prevents an
|
// Restores are deliberately limited to empty databases. This prevents an
|
||||||
// accidental merge from overwriting contributor-entered records.
|
// accidental merge from overwriting contributor-entered records.
|
||||||
for (const table of backup.tables) {
|
for (const table of backup.tables) {
|
||||||
const existing = await (
|
const existing = await (
|
||||||
await database.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`)
|
await database.prepare(
|
||||||
).get();
|
`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`,
|
||||||
if (Number(existing?.count) > 0) {
|
)
|
||||||
throw new Error(
|
).get();
|
||||||
`Restore refused: table "${table.name}" is not empty. Use a new migrated database.`
|
if (Number(existing?.count) > 0) {
|
||||||
);
|
throw new Error(
|
||||||
}
|
`Restore refused: table "${table.name}" is not empty. Use a new migrated database.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let restoredRows = 0;
|
let restoredRows = 0;
|
||||||
|
const attributeTable = backup.tables.find(
|
||||||
|
(table) => table.name === 'stat_attributes',
|
||||||
|
);
|
||||||
|
const legacySkillChipProfiles = attributeTable?.rows.some(
|
||||||
|
(row) => 'skill_chip_min' in row || 'skill_chip_max' in row,
|
||||||
|
);
|
||||||
|
const legacySkillChipRanges = new Map(
|
||||||
|
(attributeTable?.rows ?? []).map((row) => [
|
||||||
|
String(row.id),
|
||||||
|
{ min: row.skill_chip_min, max: row.skill_chip_max },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const restoredLegacySecondaries = new Set();
|
||||||
await database.exec('BEGIN');
|
await database.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
for (const table of backup.tables) {
|
for (const table of backup.tables) {
|
||||||
for (const row of table.rows) {
|
for (const row of table.rows) {
|
||||||
const columns = Object.keys(row);
|
if (
|
||||||
if (!columns.length) continue;
|
legacySkillChipProfiles &&
|
||||||
const columnSql = columns.map(quoteIdentifier).join(', ');
|
table.name === 'skill_chip_primary_options'
|
||||||
const placeholders = columns.map(() => '?').join(', ');
|
)
|
||||||
const insert = await database.prepare(
|
continue;
|
||||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`
|
const restoredRow = { ...row };
|
||||||
);
|
if (legacySkillChipProfiles && table.name === 'stat_attributes') {
|
||||||
await insert.run(...columns.map((column) => row[column]));
|
delete restoredRow.skill_chip_min;
|
||||||
restoredRows++;
|
delete restoredRow.skill_chip_max;
|
||||||
}
|
}
|
||||||
}
|
if (
|
||||||
await database.exec('COMMIT');
|
legacySkillChipProfiles &&
|
||||||
|
table.name === 'skill_chip_secondary_options'
|
||||||
|
) {
|
||||||
|
delete restoredRow.skill_chip_set_id;
|
||||||
|
const statId = String(restoredRow.stat_attribute_id ?? '');
|
||||||
|
if (restoredLegacySecondaries.has(statId)) continue;
|
||||||
|
restoredLegacySecondaries.add(statId);
|
||||||
|
}
|
||||||
|
const columns = Object.keys(restoredRow);
|
||||||
|
if (!columns.length) continue;
|
||||||
|
const columnSql = columns.map(quoteIdentifier).join(', ');
|
||||||
|
const placeholders = columns.map(() => '?').join(', ');
|
||||||
|
const insert = await database.prepare(
|
||||||
|
`INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`,
|
||||||
|
);
|
||||||
|
await insert.run(...columns.map((column) => restoredRow[column]));
|
||||||
|
restoredRows++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (legacySkillChipProfiles) {
|
||||||
|
const primaryAttributes = await (
|
||||||
|
await database.prepare(`SELECT id,active FROM stat_attributes WHERE name IN (
|
||||||
|
'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity'
|
||||||
|
)`)
|
||||||
|
).all();
|
||||||
|
if (
|
||||||
|
primaryAttributes.length !== 4 ||
|
||||||
|
primaryAttributes.some((attribute) => Number(attribute.active) !== 1)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
'Legacy restore requires exactly four active fixed Skill Chip primary Attributes.',
|
||||||
|
);
|
||||||
|
for (const attribute of primaryAttributes) {
|
||||||
|
const statId = String(attribute.id);
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'INSERT INTO skill_chip_primary_options(stat_attribute_id) VALUES(?)',
|
||||||
|
)
|
||||||
|
).run(statId);
|
||||||
|
const range = legacySkillChipRanges.get(statId);
|
||||||
|
if (!range || range.min === null || range.max === null) continue;
|
||||||
|
for (const quality of ['Standard', 'Specialized', 'Superior', 'High-end'])
|
||||||
|
await (
|
||||||
|
await database.prepare(`INSERT INTO skill_chip_primary_quality_values
|
||||||
|
(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`)
|
||||||
|
).run(statId, quality, range.min, range.max);
|
||||||
|
}
|
||||||
|
for (const statId of restoredLegacySecondaries) {
|
||||||
|
const range = legacySkillChipRanges.get(statId);
|
||||||
|
if (!range || range.min === null || range.max === null) continue;
|
||||||
|
for (const quality of ['Specialized', 'Superior', 'High-end'])
|
||||||
|
await (
|
||||||
|
await database.prepare(`INSERT INTO skill_chip_secondary_quality_values
|
||||||
|
(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`)
|
||||||
|
).run(statId, quality, range.min, range.max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await database.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Restored ${restoredRows} rows into ${databasePath}.`);
|
console.log(`Restored ${restoredRows} rows into ${databasePath}.`);
|
||||||
|
|||||||
+87
-70
@@ -2,30 +2,30 @@ import { argon2id, hash } from 'argon2';
|
|||||||
import { randomBytes, randomUUID } from 'node:crypto';
|
import { randomBytes, randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
export const defaultUsers = [
|
export const defaultUsers = [
|
||||||
{
|
{
|
||||||
username: 'Aewyn',
|
username: 'Aewyn',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
passwordEnvironmentKey: 'DEFAULT_ADMIN_PASSWORD'
|
passwordEnvironmentKey: 'DEFAULT_ADMIN_PASSWORD',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'Quartermaster',
|
username: 'Quartermaster',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
passwordEnvironmentKey: 'DEFAULT_USER_PASSWORD'
|
passwordEnvironmentKey: 'DEFAULT_USER_PASSWORD',
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function temporaryPassword() {
|
function temporaryPassword() {
|
||||||
// 120 bits of entropy encoded without punctuation that is awkward to paste.
|
// 120 bits of entropy encoded without punctuation that is awkward to paste.
|
||||||
return randomBytes(15).toString('base64url');
|
return randomBytes(15).toString('base64url');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function secureHash(password) {
|
async function secureHash(password) {
|
||||||
return hash(password, {
|
return hash(password, {
|
||||||
type: argon2id,
|
type: argon2id,
|
||||||
memoryCost: 19_456,
|
memoryCost: 19_456,
|
||||||
timeCost: 2,
|
timeCost: 2,
|
||||||
parallelism: 1
|
parallelism: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,61 +33,78 @@ async function secureHash(password) {
|
|||||||
* users keep their IDs and related records but receive new password hashes.
|
* users keep their IDs and related records but receive new password hashes.
|
||||||
*/
|
*/
|
||||||
export async function initializeDefaultUsers(database, options = {}) {
|
export async function initializeDefaultUsers(database, options = {}) {
|
||||||
const resetPasswords = options.resetPasswords === true;
|
const resetPasswords = options.resetPasswords === true;
|
||||||
const findUser = await database.prepare(
|
const findUser = await database.prepare(
|
||||||
'SELECT id, username, role FROM users WHERE login_username = ? COLLATE NOCASE OR username = ? COLLATE NOCASE'
|
'SELECT id, username, role FROM users WHERE login_username = ? COLLATE NOCASE OR username = ? COLLATE NOCASE',
|
||||||
);
|
);
|
||||||
const insertUser = await database.prepare(
|
const insertUser = await database.prepare(
|
||||||
'INSERT INTO users (id, username, login_username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?)'
|
'INSERT INTO users (id, username, login_username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
);
|
);
|
||||||
const updateCredential = await database.prepare(
|
const updateCredential = await database.prepare(
|
||||||
'UPDATE users SET login_username = ?, password_hash = ?, role = ? WHERE id = ?'
|
'UPDATE users SET login_username = ?, password_hash = ?, role = ? WHERE id = ?',
|
||||||
);
|
);
|
||||||
const credentials = [];
|
const credentials = [];
|
||||||
|
|
||||||
for (const definition of defaultUsers) {
|
for (const definition of defaultUsers) {
|
||||||
const existing = await findUser.get(definition.username, definition.username);
|
const existing = await findUser.get(
|
||||||
if (existing && !resetPasswords) {
|
definition.username,
|
||||||
console.log(`${definition.username}: already exists (${existing.role}); left unchanged`);
|
definition.username,
|
||||||
continue;
|
);
|
||||||
}
|
if (existing && !resetPasswords) {
|
||||||
|
console.log(
|
||||||
|
`${definition.username}: already exists (${existing.role}); left unchanged`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const configuredPassword = process.env[definition.passwordEnvironmentKey]?.trim();
|
const configuredPassword =
|
||||||
const password = configuredPassword || temporaryPassword();
|
process.env[definition.passwordEnvironmentKey]?.trim();
|
||||||
if (password.length < 12 || password.length > 128) {
|
const password = configuredPassword || temporaryPassword();
|
||||||
throw new Error(`${definition.passwordEnvironmentKey} must be between 12 and 128 characters.`);
|
if (password.length < 12 || password.length > 128) {
|
||||||
}
|
throw new Error(
|
||||||
const passwordHash = await secureHash(password);
|
`${definition.passwordEnvironmentKey} must be between 12 and 128 characters.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const passwordHash = await secureHash(password);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await updateCredential.run(definition.username, passwordHash, String(existing.role), existing.id);
|
await updateCredential.run(
|
||||||
console.log(`${definition.username}: credentials reset; role and related data preserved`);
|
definition.username,
|
||||||
} else {
|
passwordHash,
|
||||||
await insertUser.run(
|
String(existing.role),
|
||||||
randomUUID(),
|
existing.id,
|
||||||
definition.username,
|
);
|
||||||
definition.username,
|
console.log(
|
||||||
passwordHash,
|
`${definition.username}: credentials reset; role and related data preserved`,
|
||||||
definition.role,
|
);
|
||||||
new Date().toISOString()
|
} else {
|
||||||
);
|
await insertUser.run(
|
||||||
console.log(`${definition.username}: default account created`);
|
randomUUID(),
|
||||||
}
|
definition.username,
|
||||||
credentials.push({
|
definition.username,
|
||||||
username: definition.username,
|
passwordHash,
|
||||||
role: existing ? String(existing.role) : definition.role,
|
definition.role,
|
||||||
password,
|
new Date().toISOString(),
|
||||||
generated: !configuredPassword
|
);
|
||||||
});
|
console.log(`${definition.username}: default account created`);
|
||||||
}
|
}
|
||||||
|
credentials.push({
|
||||||
|
username: definition.username,
|
||||||
|
role: existing ? String(existing.role) : definition.role,
|
||||||
|
password,
|
||||||
|
generated: !configuredPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (credentials.length) {
|
if (credentials.length) {
|
||||||
console.log('\nDefault user credentials (shown once):');
|
console.log('\nDefault user credentials (shown once):');
|
||||||
for (const credential of credentials) {
|
for (const credential of credentials) {
|
||||||
console.log(`${credential.username} (${credential.role}): ${credential.password}${credential.generated ? ' [generated]' : ' [from environment]'}`);
|
console.log(
|
||||||
}
|
`${credential.username} (${credential.role}): ${credential.password}${credential.generated ? ' [generated]' : ' [from environment]'}`,
|
||||||
console.log('Change generated passwords after the first sign-in.\n');
|
);
|
||||||
}
|
}
|
||||||
|
console.log('Change generated passwords after the first sign-in.\n');
|
||||||
|
}
|
||||||
|
|
||||||
return credentials;
|
return credentials;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import process from 'node:process';
|
|
||||||
|
|
||||||
const files = ['.env.development', '.env.production'];
|
|
||||||
const action = process.argv[2];
|
|
||||||
const gitCommand = process.platform === 'win32' ? 'git.exe' : 'git';
|
|
||||||
const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js');
|
|
||||||
|
|
||||||
function run(command, args) {
|
|
||||||
const result = spawnSync(command, args, { cwd: process.cwd(), stdio: 'inherit' });
|
|
||||||
if (result.error) throw result.error;
|
|
||||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dotenvx(command, file) {
|
|
||||||
run(process.execPath, [dotenvxCli, command, '-f', file]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!['encrypt', 'decrypt', 'stage'].includes(action)) {
|
|
||||||
console.error('Usage: node scripts/env-files.mjs <encrypt|decrypt|stage>');
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
dotenvx(action === 'decrypt' ? 'decrypt' : 'encrypt', file);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'stage') {
|
|
||||||
run(gitCommand, ['add', '--', ...files]);
|
|
||||||
console.log('Encrypted and staged environment files.');
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const target = process.argv[2];
|
||||||
|
if (!['staging', 'production'].includes(target)) {
|
||||||
|
console.error(
|
||||||
|
'Usage: node scripts/environment-bootstrap-export.mjs <staging|production> [output.json]',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const outputArgument = process.argv[3];
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const outputPath = resolve(
|
||||||
|
outputArgument || `./data/backups/shd-${target}-bootstrap-${timestamp}.json`,
|
||||||
|
);
|
||||||
|
const temporaryBackupPath = `${outputPath}.database.json`;
|
||||||
|
const databaseBackup = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
['scripts/db-backup.mjs', temporaryBackupPath],
|
||||||
|
{
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (databaseBackup.error) throw databaseBackup.error;
|
||||||
|
if (databaseBackup.status !== 0)
|
||||||
|
throw new Error(
|
||||||
|
`Database backup failed; no ${target} bootstrap bundle was created.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function collectFiles(directory, pattern) {
|
||||||
|
try {
|
||||||
|
const names = (await readdir(directory))
|
||||||
|
.filter((name) => pattern.test(name))
|
||||||
|
.sort();
|
||||||
|
return await Promise.all(
|
||||||
|
names.map(async (name) => ({
|
||||||
|
name,
|
||||||
|
contentBase64: (await readFile(join(directory, name))).toString(
|
||||||
|
'base64',
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const database = JSON.parse(await readFile(temporaryBackupPath, 'utf8'));
|
||||||
|
const avatars = await collectFiles(
|
||||||
|
resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'),
|
||||||
|
/^[0-9a-f-]{36}\.webp$/i,
|
||||||
|
);
|
||||||
|
const gameIcons = await collectFiles(
|
||||||
|
resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'),
|
||||||
|
/^[a-z_]+-[0-9a-f-]{36}\.webp$/i,
|
||||||
|
);
|
||||||
|
const bundle = {
|
||||||
|
format: 'shd-build-environment-bootstrap',
|
||||||
|
version: 1,
|
||||||
|
target,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
database,
|
||||||
|
files: { avatars, gameIcons },
|
||||||
|
};
|
||||||
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
|
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
flag: 'wx',
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`${target} bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} managed image assets.`,
|
||||||
|
);
|
||||||
|
console.log(`Bootstrap bundle: ${outputPath}`);
|
||||||
|
} finally {
|
||||||
|
await rm(temporaryBackupPath, { force: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { connect } from '@tursodatabase/database';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const bundleArgument = process.argv
|
||||||
|
.slice(2)
|
||||||
|
.find((argument) => !argument.startsWith('--'));
|
||||||
|
const targetIndex = process.argv.indexOf('--target');
|
||||||
|
const target = targetIndex >= 0 ? process.argv[targetIndex + 1] : '';
|
||||||
|
const allowed =
|
||||||
|
target === 'staging'
|
||||||
|
? process.env.ALLOW_STAGING_BOOTSTRAP === 'true'
|
||||||
|
: target === 'production'
|
||||||
|
? process.env.ALLOW_PRODUCTION_BOOTSTRAP === 'true'
|
||||||
|
: false;
|
||||||
|
if (
|
||||||
|
!bundleArgument ||
|
||||||
|
!process.argv.includes('--confirm-empty-target-replace') ||
|
||||||
|
!allowed
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
'Environment bootstrap requires the matching ALLOW_*_BOOTSTRAP=true guard and:',
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
'node scripts/environment-bootstrap-import.mjs <bundle.json> --target <staging|production> --confirm-empty-target-replace',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundlePath = resolve(bundleArgument);
|
||||||
|
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
||||||
|
if (
|
||||||
|
bundle?.format !== 'shd-build-environment-bootstrap' ||
|
||||||
|
bundle?.version !== 1 ||
|
||||||
|
bundle?.target !== target ||
|
||||||
|
bundle.database?.format !== 'shd-build-database-backup' ||
|
||||||
|
bundle.database?.version !== 1 ||
|
||||||
|
!Array.isArray(bundle.database.tables) ||
|
||||||
|
!Array.isArray(bundle.files?.avatars) ||
|
||||||
|
!Array.isArray(bundle.files?.gameIcons)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The selected file is not a supported SHD.build bootstrap bundle for the selected target.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const file of bundle.files.avatars) {
|
||||||
|
if (
|
||||||
|
typeof file.name !== 'string' ||
|
||||||
|
!/^[0-9a-f-]{36}\.webp$/i.test(file.name) ||
|
||||||
|
typeof file.contentBase64 !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('The bootstrap bundle contains an invalid avatar file.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const file of bundle.files.gameIcons) {
|
||||||
|
if (
|
||||||
|
typeof file.name !== 'string' ||
|
||||||
|
!/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(file.name) ||
|
||||||
|
typeof file.contentBase64 !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The bootstrap bundle contains an invalid game-data icon file.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || `./data/shd-build-${target}.db`,
|
||||||
|
);
|
||||||
|
const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
if (safetyBackup.error) throw safetyBackup.error;
|
||||||
|
if (safetyBackup.status !== 0)
|
||||||
|
throw new Error(
|
||||||
|
`${target} safety backup failed; the target was not changed.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const database = await connect(databasePath);
|
||||||
|
for (const tableName of [
|
||||||
|
'users',
|
||||||
|
'builds',
|
||||||
|
'audit_log',
|
||||||
|
'data_change_proposals',
|
||||||
|
]) {
|
||||||
|
const row = await (
|
||||||
|
await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`)
|
||||||
|
).get();
|
||||||
|
if (Number(row?.count ?? 0) > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${target} bootstrap refused: target table "${tableName}" is not empty.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTables = await (
|
||||||
|
await database.prepare(`
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||||
|
ORDER BY rowid
|
||||||
|
`)
|
||||||
|
).all();
|
||||||
|
const targetNames = new Set(targetTables.map((row) => String(row.name)));
|
||||||
|
const sourceNames = new Set(
|
||||||
|
bundle.database.tables.map((table) => String(table.name)),
|
||||||
|
);
|
||||||
|
for (const row of targetTables) {
|
||||||
|
if (!sourceNames.has(String(row.name))) {
|
||||||
|
throw new Error(
|
||||||
|
`Bootstrap snapshot is missing target-schema table "${String(row.name)}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sourceOnlyTables = bundle.database.tables
|
||||||
|
.map((table) => String(table.name))
|
||||||
|
.filter((name) => !targetNames.has(name));
|
||||||
|
if (sourceOnlyTables.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const quoteIdentifier = (identifier) =>
|
||||||
|
`"${String(identifier).replaceAll('"', '""')}"`;
|
||||||
|
let restoredRows = 0;
|
||||||
|
await database.exec('PRAGMA foreign_keys=OFF');
|
||||||
|
await database.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
for (const row of [...targetTables].reverse()) {
|
||||||
|
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
|
||||||
|
}
|
||||||
|
for (const table of bundle.database.tables) {
|
||||||
|
for (const row of table.rows) {
|
||||||
|
const columns = Object.keys(row);
|
||||||
|
if (!columns.length) continue;
|
||||||
|
const insert = await database.prepare(
|
||||||
|
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
|
||||||
|
VALUES (${columns.map(() => '?').join(', ')})`,
|
||||||
|
);
|
||||||
|
await insert.run(...columns.map((column) => row[column]));
|
||||||
|
restoredRows++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await database.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await database.exec('PRAGMA foreign_keys=ON');
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarDirectory = resolve(
|
||||||
|
process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars',
|
||||||
|
);
|
||||||
|
const gameIconDirectory = resolve(
|
||||||
|
process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons',
|
||||||
|
);
|
||||||
|
await mkdir(avatarDirectory, { recursive: true });
|
||||||
|
await mkdir(gameIconDirectory, { recursive: true });
|
||||||
|
for (const file of bundle.files.avatars) {
|
||||||
|
await writeFile(
|
||||||
|
join(avatarDirectory, file.name),
|
||||||
|
Buffer.from(file.contentBase64, 'base64'),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const file of bundle.files.gameIcons) {
|
||||||
|
await writeFile(
|
||||||
|
join(gameIconDirectory, file.name),
|
||||||
|
Buffer.from(file.contentBase64, 'base64'),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Bootstrapped ${target} with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} managed image assets.`,
|
||||||
|
);
|
||||||
|
console.log(`Source bundle: ${bundlePath}`);
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { createServer } from 'vite';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const sourcePath = resolve(
|
||||||
|
process.argv[2] || './scripts/data/resurgence-attributes.json',
|
||||||
|
);
|
||||||
|
const records = JSON.parse(await readFile(sourcePath, 'utf8'));
|
||||||
|
if (!Array.isArray(records) || !records.length)
|
||||||
|
throw new Error('The Attribute import source is empty or invalid.');
|
||||||
|
|
||||||
|
const identities = new Set();
|
||||||
|
for (const record of records) {
|
||||||
|
const identity = `${String(record.name).normalize('NFKC').trim().toLocaleLowerCase()}\u0000${String(record.unit ?? '')}`;
|
||||||
|
if (identities.has(identity))
|
||||||
|
throw new Error(
|
||||||
|
`Duplicate Attribute identity in import source: ${record.name} (${record.unit || 'flat'})`,
|
||||||
|
);
|
||||||
|
identities.add(identity);
|
||||||
|
if (
|
||||||
|
!['Offensive', 'Defensive'].includes(record.type) ||
|
||||||
|
!['Weapon', 'Skill', 'Character'].includes(record.category) ||
|
||||||
|
!['Additive', 'Multiplicative'].includes(record.calculationType) ||
|
||||||
|
!['', '%', 's', 'Shots', 'm'].includes(record.unit ?? '')
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid Attribute classification in import source: ${record.name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const range of [record.gearRange, record.weaponRange]) {
|
||||||
|
if (range === undefined) continue;
|
||||||
|
if (
|
||||||
|
!Array.isArray(range) ||
|
||||||
|
range.length !== 2 ||
|
||||||
|
!range.every(Number.isFinite) ||
|
||||||
|
range[0] > range[1]
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid source range in import source: ${record.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vite = await createServer({
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
appType: 'custom',
|
||||||
|
logLevel: 'error',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const { getDatabase } = await vite.ssrLoadModule(
|
||||||
|
'/src/lib/server/database.ts',
|
||||||
|
);
|
||||||
|
const { auditRecord, recordIsacAudit } = await vite.ssrLoadModule(
|
||||||
|
'/src/lib/server/audit.ts',
|
||||||
|
);
|
||||||
|
const database = await getDatabase();
|
||||||
|
const actor = await (
|
||||||
|
await database.prepare(`SELECT users.id FROM users
|
||||||
|
LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
||||||
|
LEFT JOIN user_roles link ON link.user_id=users.id
|
||||||
|
WHERE users.role='admin' OR link.role_id='role-admin'
|
||||||
|
ORDER BY CASE WHEN users.username=? COLLATE NOCASE OR profile.display_name=? COLLATE NOCASE THEN 0 ELSE 1 END,users.created_at
|
||||||
|
LIMIT 1`)
|
||||||
|
).get('Aewyn', 'Aewyn');
|
||||||
|
if (!actor?.id)
|
||||||
|
throw new Error(
|
||||||
|
'No administrator account is available to own the Attribute import audit events.',
|
||||||
|
);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let unchanged = 0;
|
||||||
|
await database.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
for (const record of records) {
|
||||||
|
const name = String(record.name).normalize('NFKC').trim();
|
||||||
|
const unit = String(record.unit ?? '');
|
||||||
|
const existing = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT * FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?',
|
||||||
|
)
|
||||||
|
).get(name, unit);
|
||||||
|
const id = String(existing?.id ?? randomUUID());
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const next = {
|
||||||
|
...(existing ?? {}),
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description: existing?.description ?? null,
|
||||||
|
type: record.type,
|
||||||
|
category: record.category,
|
||||||
|
calculation_type: record.calculationType,
|
||||||
|
unit,
|
||||||
|
gear_min: record.gearRange?.[0] ?? existing?.gear_min ?? null,
|
||||||
|
gear_max: record.gearRange?.[1] ?? existing?.gear_max ?? null,
|
||||||
|
gear_reference_level: Number(existing?.gear_reference_level ?? 50),
|
||||||
|
weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null,
|
||||||
|
weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null,
|
||||||
|
weapon_reference_level: Number(existing?.weapon_reference_level ?? 50),
|
||||||
|
cap_value: existing?.cap_value ?? null,
|
||||||
|
active: 1,
|
||||||
|
created_at: String(existing?.created_at ?? now),
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
const comparableExisting = existing
|
||||||
|
? { ...existing, updated_at: now }
|
||||||
|
: null;
|
||||||
|
if (comparableExisting && isDeepStrictEqual(comparableExisting, next)) {
|
||||||
|
unchanged++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const before = existing ? await auditRecord('stat_attributes', id) : null;
|
||||||
|
if (existing) {
|
||||||
|
await (
|
||||||
|
await database.prepare(`UPDATE stat_attributes SET
|
||||||
|
name=?,description=?,type=?,category=?,calculation_type=?,unit=?,
|
||||||
|
gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,
|
||||||
|
cap_value=?,active=1,updated_at=? WHERE id=?`)
|
||||||
|
).run(
|
||||||
|
next.name,
|
||||||
|
next.description,
|
||||||
|
next.type,
|
||||||
|
next.category,
|
||||||
|
next.calculation_type,
|
||||||
|
next.unit,
|
||||||
|
next.gear_min,
|
||||||
|
next.gear_max,
|
||||||
|
next.weapon_min,
|
||||||
|
next.weapon_max,
|
||||||
|
next.cap_value,
|
||||||
|
now,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
await (
|
||||||
|
await database.prepare(`INSERT INTO stat_attributes(
|
||||||
|
id,name,description,type,category,calculation_type,unit,
|
||||||
|
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
|
||||||
|
cap_value,active,created_at,updated_at
|
||||||
|
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
|
||||||
|
).run(
|
||||||
|
id,
|
||||||
|
next.name,
|
||||||
|
next.description,
|
||||||
|
next.type,
|
||||||
|
next.category,
|
||||||
|
next.calculation_type,
|
||||||
|
next.unit,
|
||||||
|
next.gear_min,
|
||||||
|
next.gear_max,
|
||||||
|
next.gear_reference_level,
|
||||||
|
next.weapon_min,
|
||||||
|
next.weapon_max,
|
||||||
|
next.weapon_reference_level,
|
||||||
|
next.cap_value,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
await recordIsacAudit({
|
||||||
|
actorId: String(actor.id),
|
||||||
|
category: 'game_data',
|
||||||
|
action: existing ? 'attribute.updated' : 'attribute.created',
|
||||||
|
entityId: id,
|
||||||
|
before,
|
||||||
|
after: await auditRecord('stat_attributes', id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await database.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`Imported ${records.length} Attribute definitions: ${created} created, ${updated} updated, ${unchanged} unchanged.`,
|
||||||
|
);
|
||||||
|
console.log(`Source: ${sourcePath}`);
|
||||||
|
} finally {
|
||||||
|
await vite.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { connect } from '@tursodatabase/database';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { basename, dirname, join, resolve } from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import {
|
||||||
|
ISAC_DATA_FORMAT_VERSION,
|
||||||
|
ISAC_DATA_TABLES,
|
||||||
|
quoteIdentifier,
|
||||||
|
} from './isac-data-tables.mjs';
|
||||||
|
|
||||||
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
|
);
|
||||||
|
const iconDirectory = resolve(
|
||||||
|
process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons',
|
||||||
|
);
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const outputPath = resolve(
|
||||||
|
process.argv[2] || `./data/backups/isac-data-${timestamp}.json`,
|
||||||
|
);
|
||||||
|
const database = await connect(databasePath);
|
||||||
|
const tables = [];
|
||||||
|
let totalRows = 0;
|
||||||
|
|
||||||
|
for (const name of ISAC_DATA_TABLES) {
|
||||||
|
const exists = await (
|
||||||
|
await database.prepare(
|
||||||
|
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
|
)
|
||||||
|
).get(name);
|
||||||
|
if (!exists)
|
||||||
|
throw new Error(
|
||||||
|
`ISAC export requires missing table "${name}". Run the schema migration first.`,
|
||||||
|
);
|
||||||
|
const rows = await (
|
||||||
|
await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)
|
||||||
|
).all();
|
||||||
|
tables.push({ name, rowCount: rows.length, rows });
|
||||||
|
totalRows += rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = [];
|
||||||
|
const exportedStorageKeys = new Set();
|
||||||
|
const iconTable = tables.find((table) => table.name === 'game_data_icons');
|
||||||
|
const managedImageTable = tables.find(
|
||||||
|
(table) => table.name === 'managed_images',
|
||||||
|
);
|
||||||
|
for (const row of [
|
||||||
|
...(iconTable?.rows ?? []),
|
||||||
|
...(managedImageTable?.rows ?? []),
|
||||||
|
]) {
|
||||||
|
if (
|
||||||
|
typeof row.storage_key !== 'string' ||
|
||||||
|
!/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid managed asset storage key "${String(row.storage_key)}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (exportedStorageKeys.has(row.storage_key)) continue;
|
||||||
|
exportedStorageKeys.add(row.storage_key);
|
||||||
|
const content = await readFile(join(iconDirectory, row.storage_key));
|
||||||
|
icons.push({
|
||||||
|
storageKey: row.storage_key,
|
||||||
|
contentBase64: content.toString('base64'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
format: 'shd-build-isac-data',
|
||||||
|
version: ISAC_DATA_FORMAT_VERSION,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
sourceDatabase: basename(databasePath),
|
||||||
|
totalRows,
|
||||||
|
tables,
|
||||||
|
icons,
|
||||||
|
};
|
||||||
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
|
await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
flag: 'wx',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Exported ${totalRows} ISAC rows and ${icons.length} uploaded image assets.`,
|
||||||
|
);
|
||||||
|
console.log(`ISAC snapshot: ${outputPath}`);
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import { connect } from '@tursodatabase/database';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { basename, join, resolve } from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import {
|
||||||
|
ISAC_DATA_FORMAT_VERSION,
|
||||||
|
ISAC_DATA_TABLES,
|
||||||
|
quoteIdentifier,
|
||||||
|
} from './isac-data-tables.mjs';
|
||||||
|
|
||||||
|
const snapshotArgument = process.argv
|
||||||
|
.slice(2)
|
||||||
|
.find((argument) => !argument.startsWith('--'));
|
||||||
|
if (
|
||||||
|
!snapshotArgument ||
|
||||||
|
!process.argv.includes('--confirm-replace-isac-data')
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
'Usage: node scripts/isac-data-import.mjs <snapshot.json> --confirm-replace-isac-data',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshotPath = resolve(snapshotArgument);
|
||||||
|
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
|
||||||
|
if (
|
||||||
|
snapshot?.format !== 'shd-build-isac-data' ||
|
||||||
|
snapshot?.version !== ISAC_DATA_FORMAT_VERSION ||
|
||||||
|
!Array.isArray(snapshot.tables) ||
|
||||||
|
!Array.isArray(snapshot.icons)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The selected file is not a supported SHD.build ISAC data snapshot.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const receivedNames = snapshot.tables.map((table) => table.name);
|
||||||
|
if (
|
||||||
|
receivedNames.length !== ISAC_DATA_TABLES.length ||
|
||||||
|
ISAC_DATA_TABLES.some((name, index) => receivedNames[index] !== name)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The ISAC snapshot table manifest is incomplete or out of order.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const abilityRows = snapshot.tables.find(
|
||||||
|
(table) => table.name === 'specialization_abilities',
|
||||||
|
).rows;
|
||||||
|
const abilitiesById = new Map(
|
||||||
|
abilityRows.map((row) => [String(row.id ?? ''), row]),
|
||||||
|
);
|
||||||
|
const skillChipRows = snapshot.tables.find(
|
||||||
|
(table) => table.name === 'skill_chip_sets',
|
||||||
|
).rows;
|
||||||
|
const activeSkillChipCounts = new Map();
|
||||||
|
for (const row of skillChipRows) {
|
||||||
|
const abilityId = String(row.specialization_ability_id ?? '');
|
||||||
|
if (!abilityId || !abilitiesById.has(abilityId)) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a Skill Chip definition with a missing ability reference "${abilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Number(row.active) !== 1) continue;
|
||||||
|
const count = (activeSkillChipCounts.get(abilityId) ?? 0) + 1;
|
||||||
|
if (count > 3) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot assigns more than three active Skill Chip definitions to ability "${abilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
activeSkillChipCounts.set(abilityId, count);
|
||||||
|
}
|
||||||
|
const skillChipLinks = snapshot.tables.find(
|
||||||
|
(table) => table.name === 'specialization_ability_skill_chip_links',
|
||||||
|
).rows;
|
||||||
|
const linkedAbilityIds = new Set();
|
||||||
|
for (const row of skillChipLinks) {
|
||||||
|
const abilityId = row.ability_id;
|
||||||
|
const compatibleAbilityId = row.compatible_ability_id;
|
||||||
|
if (
|
||||||
|
typeof abilityId !== 'string' ||
|
||||||
|
!abilityId ||
|
||||||
|
typeof compatibleAbilityId !== 'string' ||
|
||||||
|
!compatibleAbilityId
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The ISAC snapshot contains a Skill Chip sharing link with malformed ability references.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (abilityId === compatibleAbilityId) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a self-referencing Skill Chip sharing link for ability "${abilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (abilityId >= compatibleAbilityId) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a Skill Chip sharing link that is not in canonical ability order: "${abilityId}", "${compatibleAbilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ability = abilitiesById.get(abilityId);
|
||||||
|
const compatibleAbility = abilitiesById.get(compatibleAbilityId);
|
||||||
|
if (!ability || !compatibleAbility) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a Skill Chip sharing link with a missing ability reference: "${abilityId}", "${compatibleAbilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Number(ability.active) !== 1 || Number(compatibleAbility.active) !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a Skill Chip sharing link with an inactive ability: "${abilityId}", "${compatibleAbilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ability.specialization_id !== compatibleAbility.specialization_id) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a cross-specialization Skill Chip sharing link: "${abilityId}", "${compatibleAbilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
linkedAbilityIds.has(abilityId) ||
|
||||||
|
linkedAbilityIds.has(compatibleAbilityId)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The ISAC snapshot contains more than one Skill Chip sharing link touching an ability.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
linkedAbilityIds.add(abilityId);
|
||||||
|
linkedAbilityIds.add(compatibleAbilityId);
|
||||||
|
}
|
||||||
|
for (const kind of ['weapon', 'gear']) {
|
||||||
|
const options = snapshot.tables.find(
|
||||||
|
(table) => table.name === `${kind}_mod_secondary_options`,
|
||||||
|
).rows;
|
||||||
|
const values = snapshot.tables.find(
|
||||||
|
(table) => table.name === `${kind}_mod_secondary_quality_values`,
|
||||||
|
).rows;
|
||||||
|
const optionIds = options.map((row) => String(row.stat_attribute_id ?? ''));
|
||||||
|
if (
|
||||||
|
optionIds.some((id) => !id) ||
|
||||||
|
new Set(optionIds).size !== optionIds.length ||
|
||||||
|
(optionIds.length !== 0 &&
|
||||||
|
(optionIds.length < 2 || optionIds.length > 50)) ||
|
||||||
|
values.length !== optionIds.length * 3
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains an incomplete shared ${kind} Mod secondary pool.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const statId of optionIds) {
|
||||||
|
const profile = values.filter(
|
||||||
|
(row) => String(row.stat_attribute_id) === statId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
profile.length !== 3 ||
|
||||||
|
['Specialized', 'Superior', 'High-end'].some(
|
||||||
|
(quality) => !profile.some((row) => row.quality === quality),
|
||||||
|
) ||
|
||||||
|
profile.some((row) => {
|
||||||
|
const endpoints = [
|
||||||
|
row.level_1_min,
|
||||||
|
row.level_1_max,
|
||||||
|
row.level_20_min,
|
||||||
|
row.level_20_max,
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
endpoints.some(
|
||||||
|
(value) => typeof value !== 'number' || !Number.isFinite(value),
|
||||||
|
) ||
|
||||||
|
endpoints[1] < endpoints[0] ||
|
||||||
|
endpoints[2] < endpoints[0] ||
|
||||||
|
endpoints[3] < endpoints[1] ||
|
||||||
|
endpoints[3] < endpoints[2]
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains an invalid shared ${kind} Mod secondary profile for ${statId}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
values.some(
|
||||||
|
(row) => !optionIds.includes(String(row.stat_attribute_id ?? '')),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains an orphaned shared ${kind} Mod secondary range.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [profileName, qualities, requiredOptions] of [
|
||||||
|
['primary', ['Standard', 'Specialized', 'Superior', 'High-end'], 4],
|
||||||
|
['secondary', ['Specialized', 'Superior', 'High-end'], null],
|
||||||
|
]) {
|
||||||
|
const options = snapshot.tables.find(
|
||||||
|
(table) => table.name === `skill_chip_${profileName}_options`,
|
||||||
|
).rows;
|
||||||
|
const values = snapshot.tables.find(
|
||||||
|
(table) => table.name === `skill_chip_${profileName}_quality_values`,
|
||||||
|
).rows;
|
||||||
|
const optionIds = options.map((row) => String(row.stat_attribute_id ?? ''));
|
||||||
|
const validCardinality = requiredOptions
|
||||||
|
? optionIds.length === requiredOptions
|
||||||
|
: optionIds.length <= 50;
|
||||||
|
if (
|
||||||
|
!validCardinality ||
|
||||||
|
optionIds.some((id) => !id) ||
|
||||||
|
new Set(optionIds).size !== optionIds.length
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains an incomplete shared Skill Chip ${profileName} profile.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (profileName === 'primary' && optionIds.length) {
|
||||||
|
const attributes = snapshot.tables.find(
|
||||||
|
(table) => table.name === 'stat_attributes',
|
||||||
|
).rows;
|
||||||
|
const expectedIds = attributes
|
||||||
|
.filter(
|
||||||
|
(row) =>
|
||||||
|
Number(row.active) === 1 &&
|
||||||
|
[
|
||||||
|
'Skill Duration',
|
||||||
|
'Skill Radius',
|
||||||
|
'Skill Cooldown Recovery',
|
||||||
|
'Skill Intensity',
|
||||||
|
].includes(String(row.name)),
|
||||||
|
)
|
||||||
|
.map((row) => String(row.id))
|
||||||
|
.sort();
|
||||||
|
if (
|
||||||
|
expectedIds.length !== 4 ||
|
||||||
|
optionIds
|
||||||
|
.slice()
|
||||||
|
.sort()
|
||||||
|
.some((id, index) => id !== expectedIds[index])
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
'The ISAC snapshot does not use the four fixed Skill Chip primary Attributes.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const statId of optionIds) {
|
||||||
|
const rows = values.filter(
|
||||||
|
(row) => String(row.stat_attribute_id ?? '') === statId,
|
||||||
|
);
|
||||||
|
if (!rows.length) continue;
|
||||||
|
if (
|
||||||
|
rows.length !== qualities.length ||
|
||||||
|
qualities.some(
|
||||||
|
(quality) => !rows.some((row) => row.quality === quality),
|
||||||
|
) ||
|
||||||
|
rows.some(
|
||||||
|
(row) =>
|
||||||
|
typeof row.min_value !== 'number' ||
|
||||||
|
!Number.isFinite(row.min_value) ||
|
||||||
|
typeof row.max_value !== 'number' ||
|
||||||
|
!Number.isFinite(row.max_value) ||
|
||||||
|
row.min_value < 0 ||
|
||||||
|
row.max_value < row.min_value,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains an invalid shared Skill Chip ${profileName} profile for ${statId}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
values.some(
|
||||||
|
(row) => !optionIds.includes(String(row.stat_attribute_id ?? '')),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains an orphaned shared Skill Chip ${profileName} range.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const icon of snapshot.icons) {
|
||||||
|
if (
|
||||||
|
typeof icon.storageKey !== 'string' ||
|
||||||
|
!/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(icon.storageKey) ||
|
||||||
|
typeof icon.contentBase64 !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('The ISAC snapshot contains an invalid uploaded icon.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
|
);
|
||||||
|
const backupPath = resolve(
|
||||||
|
`./data/backups/${basename(databasePath).replace(/\.[^.]+$/, '')}-before-isac-import-${timestamp}.json`,
|
||||||
|
);
|
||||||
|
const isacBackupPath = resolve(
|
||||||
|
`./data/backups/isac-data-before-import-${timestamp}.json`,
|
||||||
|
);
|
||||||
|
for (const [script, output] of [
|
||||||
|
['scripts/db-backup.mjs', backupPath],
|
||||||
|
['scripts/isac-data-export.mjs', isacBackupPath],
|
||||||
|
]) {
|
||||||
|
const result = spawnSync(process.execPath, [script, output], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0)
|
||||||
|
throw new Error(
|
||||||
|
`Safety backup failed in ${script}; no ISAC data was changed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = await connect(databasePath);
|
||||||
|
let importedRows = 0;
|
||||||
|
await database.exec('PRAGMA foreign_keys=OFF');
|
||||||
|
await database.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
for (const name of [...ISAC_DATA_TABLES].reverse()) {
|
||||||
|
await database.exec(`DELETE FROM ${quoteIdentifier(name)}`);
|
||||||
|
}
|
||||||
|
for (const table of snapshot.tables) {
|
||||||
|
for (const row of table.rows) {
|
||||||
|
const columns = Object.keys(row);
|
||||||
|
if (!columns.length) continue;
|
||||||
|
const insert = await database.prepare(
|
||||||
|
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
|
||||||
|
VALUES (${columns.map(() => '?').join(', ')})`,
|
||||||
|
);
|
||||||
|
await insert.run(...columns.map((column) => row[column]));
|
||||||
|
importedRows++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await database.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await database.exec('PRAGMA foreign_keys=ON');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconDirectory = resolve(
|
||||||
|
process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons',
|
||||||
|
);
|
||||||
|
await mkdir(iconDirectory, { recursive: true });
|
||||||
|
for (const icon of snapshot.icons) {
|
||||||
|
await writeFile(
|
||||||
|
join(iconDirectory, icon.storageKey),
|
||||||
|
Buffer.from(icon.contentBase64, 'base64'),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Imported ${importedRows} ISAC rows and ${snapshot.icons.length} uploaded image assets from ${snapshotPath}.`,
|
||||||
|
);
|
||||||
|
console.log(`Full pre-import backup: ${backupPath}`);
|
||||||
|
console.log(`ISAC pre-import snapshot: ${isacBackupPath}`);
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
export const ISAC_DATA_TABLES = [
|
||||||
|
'stat_attributes',
|
||||||
|
'talents',
|
||||||
|
'talent_tiers',
|
||||||
|
'conditional_effect_rules',
|
||||||
|
'conditional_effect_state_pools',
|
||||||
|
'conditional_effect_clauses',
|
||||||
|
'conditional_effect_outcomes',
|
||||||
|
'talent_tier_effects',
|
||||||
|
'talent_description_templates',
|
||||||
|
'talent_weapon_scopes',
|
||||||
|
'weapon_talent_slots',
|
||||||
|
'brand_sets',
|
||||||
|
'brand_set_effects',
|
||||||
|
'gear',
|
||||||
|
'gear_brand_sets',
|
||||||
|
'gear_talents',
|
||||||
|
'gear_tactical_augments',
|
||||||
|
'gear_tactical_augment_costs',
|
||||||
|
'weapons',
|
||||||
|
'core_attribute_tier_ranges',
|
||||||
|
'weapon_tier_ranges',
|
||||||
|
'weapon_talent_options',
|
||||||
|
'weapon_augmentation_effects',
|
||||||
|
'weapon_category_attribute_bonuses',
|
||||||
|
'os_protocols',
|
||||||
|
'os_protocol_primary_effects',
|
||||||
|
'os_protocol_secondary_profiles',
|
||||||
|
'os_protocol_secondary_effects',
|
||||||
|
'os_protocol_talents',
|
||||||
|
'os_protocol_talent_effects',
|
||||||
|
'skill_chip_sets',
|
||||||
|
'skill_chip_two_piece_bonuses',
|
||||||
|
'skill_chip_primary_options',
|
||||||
|
'skill_chip_primary_quality_values',
|
||||||
|
'skill_chip_secondary_options',
|
||||||
|
'skill_chip_secondary_quality_values',
|
||||||
|
'skill_chip_talents',
|
||||||
|
'skill_chip_talent_effects',
|
||||||
|
'weapon_mods',
|
||||||
|
'weapon_mod_compatible_categories',
|
||||||
|
'weapon_mod_slots',
|
||||||
|
'weapon_mod_quality_values',
|
||||||
|
'weapon_mod_secondary_options',
|
||||||
|
'weapon_mod_secondary_quality_values',
|
||||||
|
'weapon_mod_exotic_attributes',
|
||||||
|
'weapon_mod_effects',
|
||||||
|
'weapon_mod_effect_quality_values',
|
||||||
|
'gear_mods',
|
||||||
|
'gear_mod_quality_values',
|
||||||
|
'gear_mod_secondary_options',
|
||||||
|
'gear_mod_secondary_quality_values',
|
||||||
|
'specialization_progression_config',
|
||||||
|
'specialization_rank_cost_profiles',
|
||||||
|
'upgrade_resources',
|
||||||
|
'specializations',
|
||||||
|
'specialization_combat_effects',
|
||||||
|
'specialization_focuses',
|
||||||
|
'specialization_abilities',
|
||||||
|
'specialization_ability_skill_chip_links',
|
||||||
|
'specialization_ability_attacks',
|
||||||
|
'specialization_ability_effects',
|
||||||
|
'specialization_focus_abilities',
|
||||||
|
'specialization_tree_nodes',
|
||||||
|
'specialization_tree_node_ranks',
|
||||||
|
'specialization_tree_rank_effects',
|
||||||
|
'specialization_tree_rank_costs',
|
||||||
|
'specialization_tree_rank_combat_effects',
|
||||||
|
'specialization_tree_rank_combat_modifiers',
|
||||||
|
'calculation_revisions',
|
||||||
|
'calculation_stages',
|
||||||
|
'attribute_calculation_profiles',
|
||||||
|
'skill_attribute_calculation_profiles',
|
||||||
|
'calculation_calibration_cases',
|
||||||
|
'game_data_icons',
|
||||||
|
'managed_images',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ISAC_DATA_FORMAT_VERSION = 5;
|
||||||
|
|
||||||
|
export const quoteIdentifier = (identifier) =>
|
||||||
|
`"${String(identifier).replaceAll('"', '""')}"`;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { connect } from '@tursodatabase/database';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { basename, dirname, join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
|
);
|
||||||
|
const storageDirectory = resolve(
|
||||||
|
process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons',
|
||||||
|
);
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const outputPath = resolve(
|
||||||
|
process.argv[2] || `./data/backups/managed-images-${timestamp}.json`,
|
||||||
|
);
|
||||||
|
const database = await connect(databasePath);
|
||||||
|
const rows = await (
|
||||||
|
await database.prepare('SELECT * FROM managed_images ORDER BY created_at')
|
||||||
|
).all();
|
||||||
|
const images = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (
|
||||||
|
typeof row.storage_key !== 'string' ||
|
||||||
|
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i.test(
|
||||||
|
row.storage_key,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid managed-image storage key "${String(row.storage_key)}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
images.push({
|
||||||
|
row,
|
||||||
|
contentBase64: (
|
||||||
|
await readFile(join(storageDirectory, row.storage_key))
|
||||||
|
).toString('base64'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const bundle = {
|
||||||
|
format: 'shd-build-managed-images',
|
||||||
|
version: 1,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
sourceDatabase: basename(databasePath),
|
||||||
|
images,
|
||||||
|
};
|
||||||
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
|
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
flag: 'wx',
|
||||||
|
});
|
||||||
|
console.log(`Exported ${images.length} managed image assets.`);
|
||||||
|
console.log(`Image bundle: ${outputPath}`);
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { connect } from '@tursodatabase/database';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
const bundleArgument = process.argv
|
||||||
|
.slice(2)
|
||||||
|
.find((argument) => !argument.startsWith('--'));
|
||||||
|
if (!bundleArgument || !process.argv.includes('--confirm-merge')) {
|
||||||
|
console.error(
|
||||||
|
'Usage: node scripts/managed-images-import.mjs <bundle.json> --confirm-merge [--update-existing]',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const updateExisting = process.argv.includes('--update-existing');
|
||||||
|
const gameDataRenderSourceTypes = new Set([
|
||||||
|
'weapon_render',
|
||||||
|
'gear_render',
|
||||||
|
'weapon_mod_render',
|
||||||
|
'gear_mod_render',
|
||||||
|
'talent_render',
|
||||||
|
'brand_set_render',
|
||||||
|
'os_protocol_render',
|
||||||
|
'skill_chip_render',
|
||||||
|
'specialization_render',
|
||||||
|
'specialization_ability_render',
|
||||||
|
'specialization_node_render',
|
||||||
|
]);
|
||||||
|
const bundlePath = resolve(bundleArgument);
|
||||||
|
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
||||||
|
if (
|
||||||
|
bundle?.format !== 'shd-build-managed-images' ||
|
||||||
|
bundle?.version !== 1 ||
|
||||||
|
!Array.isArray(bundle.images)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The selected file is not a supported SHD.build managed-image bundle.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const image of bundle.images) {
|
||||||
|
if (
|
||||||
|
typeof image?.row?.id !== 'string' ||
|
||||||
|
!/^[0-9a-f-]{36}$/i.test(image.row.id) ||
|
||||||
|
typeof image.row.storage_key !== 'string' ||
|
||||||
|
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i.test(
|
||||||
|
image.row.storage_key,
|
||||||
|
) ||
|
||||||
|
typeof image.contentBase64 !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('The bundle contains an invalid managed image.');
|
||||||
|
}
|
||||||
|
const isGameDataRender = gameDataRenderSourceTypes.has(
|
||||||
|
image.row.source_entity_type,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
image.row.source_entity_type &&
|
||||||
|
(typeof image.row.source_entity_id !== 'string' ||
|
||||||
|
!/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) ||
|
||||||
|
(isGameDataRender
|
||||||
|
? !image.row.storage_key.startsWith('library-')
|
||||||
|
: !image.row.storage_key.startsWith(
|
||||||
|
`${image.row.source_entity_type}-`,
|
||||||
|
)))
|
||||||
|
)
|
||||||
|
throw new Error('The bundle contains an invalid linked ISAC image.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const backup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
if (backup.error) throw backup.error;
|
||||||
|
if (backup.status !== 0)
|
||||||
|
throw new Error('Safety backup failed; no managed images were imported.');
|
||||||
|
const assetBackup = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
['scripts/isac-data-export.mjs'],
|
||||||
|
{
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (assetBackup.error) throw assetBackup.error;
|
||||||
|
if (assetBackup.status !== 0)
|
||||||
|
throw new Error(
|
||||||
|
'Uploaded-asset safety snapshot failed; no managed images were imported.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
|
);
|
||||||
|
const storageDirectory = resolve(
|
||||||
|
process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons',
|
||||||
|
);
|
||||||
|
const database = await connect(databasePath);
|
||||||
|
await mkdir(storageDirectory, { recursive: true });
|
||||||
|
let inserted = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let unchanged = 0;
|
||||||
|
|
||||||
|
const preflight = [];
|
||||||
|
for (const image of bundle.images) {
|
||||||
|
const row = image.row;
|
||||||
|
const existing = await (
|
||||||
|
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||||
|
).get(row.id);
|
||||||
|
if (existing && !updateExisting) {
|
||||||
|
const same = [
|
||||||
|
'name',
|
||||||
|
'category',
|
||||||
|
'tags_json',
|
||||||
|
'is_icon',
|
||||||
|
'force_white',
|
||||||
|
'storage_key',
|
||||||
|
'byte_size',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'source_entity_type',
|
||||||
|
'source_entity_id',
|
||||||
|
].every((key) => String(existing[key]) === String(row[key]));
|
||||||
|
if (!same)
|
||||||
|
throw new Error(
|
||||||
|
`Image ${row.id} already exists with different metadata. Re-run with --update-existing after review.`,
|
||||||
|
);
|
||||||
|
unchanged++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const creator = await (
|
||||||
|
await database.prepare('SELECT id FROM users WHERE id=?')
|
||||||
|
).get(row.created_by);
|
||||||
|
if (!creator)
|
||||||
|
throw new Error(
|
||||||
|
`Image "${row.name}" references a creator that does not exist in the target database.`,
|
||||||
|
);
|
||||||
|
const nameConflict = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT id FROM managed_images WHERE name=? COLLATE NOCASE AND id<>?',
|
||||||
|
)
|
||||||
|
).get(row.name, row.id);
|
||||||
|
if (nameConflict)
|
||||||
|
throw new Error(
|
||||||
|
`Image name "${row.name}" is already assigned to a different target asset.`,
|
||||||
|
);
|
||||||
|
if (row.source_entity_type && row.source_entity_id) {
|
||||||
|
const sourceConflict = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT id FROM managed_images WHERE source_entity_type=? AND source_entity_id=? AND id<>?',
|
||||||
|
)
|
||||||
|
).get(row.source_entity_type, row.source_entity_id, row.id);
|
||||||
|
if (sourceConflict)
|
||||||
|
throw new Error(
|
||||||
|
`The ${row.source_entity_type} image ${row.source_entity_id} is already registered under a different library asset.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
preflight.push({ image, existing });
|
||||||
|
}
|
||||||
|
|
||||||
|
await database.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
for (const { image, existing } of preflight) {
|
||||||
|
const row = image.row;
|
||||||
|
await writeFile(
|
||||||
|
join(storageDirectory, row.storage_key),
|
||||||
|
Buffer.from(image.contentBase64, 'base64'),
|
||||||
|
{
|
||||||
|
mode: 0o600,
|
||||||
|
flag: existing || row.source_entity_type ? 'w' : 'wx',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
await (
|
||||||
|
await database.prepare(`UPDATE managed_images SET
|
||||||
|
name=?,category=?,tags_json=?,is_icon=?,force_white=?,storage_key=?,original_filename=?,
|
||||||
|
byte_size=?,width=?,height=?,source_entity_type=?,source_entity_id=?,updated_at=? WHERE id=?`)
|
||||||
|
).run(
|
||||||
|
row.name,
|
||||||
|
row.category,
|
||||||
|
row.tags_json,
|
||||||
|
row.is_icon,
|
||||||
|
row.force_white,
|
||||||
|
row.storage_key,
|
||||||
|
row.original_filename,
|
||||||
|
row.byte_size,
|
||||||
|
row.width,
|
||||||
|
row.height,
|
||||||
|
row.source_entity_type ?? null,
|
||||||
|
row.source_entity_id ?? null,
|
||||||
|
row.updated_at,
|
||||||
|
row.id,
|
||||||
|
);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
await (
|
||||||
|
await database.prepare(`INSERT INTO managed_images
|
||||||
|
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
|
||||||
|
source_entity_type,source_entity_id,created_by,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||||
|
).run(
|
||||||
|
row.id,
|
||||||
|
row.name,
|
||||||
|
row.category,
|
||||||
|
row.tags_json,
|
||||||
|
row.is_icon,
|
||||||
|
row.force_white,
|
||||||
|
row.storage_key,
|
||||||
|
row.original_filename,
|
||||||
|
row.byte_size,
|
||||||
|
row.width,
|
||||||
|
row.height,
|
||||||
|
row.source_entity_type ?? null,
|
||||||
|
row.source_entity_id ?? null,
|
||||||
|
row.created_by,
|
||||||
|
row.created_at,
|
||||||
|
row.updated_at,
|
||||||
|
);
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
await (
|
||||||
|
await database.prepare(`INSERT INTO audit_log
|
||||||
|
(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)`)
|
||||||
|
).run(
|
||||||
|
randomUUID(),
|
||||||
|
row.created_by,
|
||||||
|
'system_configuration',
|
||||||
|
row.id,
|
||||||
|
existing ? 'image.import_updated' : 'image.imported',
|
||||||
|
existing ? JSON.stringify(existing) : null,
|
||||||
|
JSON.stringify(row),
|
||||||
|
new Date().toISOString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await database.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`Managed-image merge complete: ${inserted} inserted, ${updated} updated, ${unchanged} unchanged.`,
|
||||||
|
);
|
||||||
@@ -2,40 +2,78 @@ import { connect } from '@tursodatabase/database';
|
|||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
if (!process.env.SMTP_HOST || !process.env.SMTP_FROM) throw new Error('SMTP delivery is not configured.');
|
if (!process.env.SMTP_HOST || !process.env.SMTP_FROM)
|
||||||
const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
|
throw new Error('SMTP delivery is not configured.');
|
||||||
|
const database = await connect(
|
||||||
|
resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'),
|
||||||
|
);
|
||||||
const transport = nodemailer.createTransport({
|
const transport = nodemailer.createTransport({
|
||||||
host: process.env.SMTP_HOST,
|
host: process.env.SMTP_HOST,
|
||||||
port: Number(process.env.SMTP_PORT || 587),
|
port: Number(process.env.SMTP_PORT || 587),
|
||||||
secure: process.env.SMTP_SECURE === 'true',
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
|
auth: process.env.SMTP_USER
|
||||||
requireTLS: process.env.SMTP_SECURE !== 'true',
|
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
|
||||||
disableFileAccess: true,
|
: undefined,
|
||||||
disableUrlAccess: true
|
requireTLS: process.env.SMTP_SECURE !== 'true',
|
||||||
|
disableFileAccess: true,
|
||||||
|
disableUrlAccess: true,
|
||||||
});
|
});
|
||||||
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(
|
||||||
const recipients = await (await database.prepare(`SELECT DISTINCT n.user_id,e.email
|
/\/+$/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const recipients = await (
|
||||||
|
await database.prepare(`SELECT DISTINCT n.user_id,e.email
|
||||||
FROM notifications n
|
FROM notifications n
|
||||||
JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily'
|
JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily'
|
||||||
JOIN user_emails e ON e.user_id=n.user_id
|
JOIN user_emails e ON e.user_id=n.user_id
|
||||||
WHERE n.emailed_at IS NULL AND n.created_at>=p.updated_at`)).all();
|
WHERE n.emailed_at IS NULL AND n.created_at>=p.updated_at`)
|
||||||
|
).all();
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
for (const recipient of recipients) {
|
for (const recipient of recipients) {
|
||||||
const items = await (await database.prepare(`SELECT n.id,n.title,n.body,n.link FROM notifications n
|
const items = await (
|
||||||
|
await database.prepare(`SELECT n.id,n.title,n.body,n.link FROM notifications n
|
||||||
JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily'
|
JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily'
|
||||||
WHERE n.user_id=? AND n.emailed_at IS NULL AND n.created_at>=p.updated_at ORDER BY n.created_at LIMIT 100`)).all(recipient.user_id);
|
WHERE n.user_id=? AND n.emailed_at IS NULL AND n.created_at>=p.updated_at ORDER BY n.created_at LIMIT 100`)
|
||||||
if (!items.length) continue;
|
).all(recipient.user_id);
|
||||||
const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]);
|
if (!items.length) continue;
|
||||||
const rows = items.map((item) => `<div style="border-top:1px solid #34393a;padding:16px 0"><strong>${escape(item.title)}</strong><p style="color:#a5aaab">${escape(item.body)}</p>${item.link ? `<a style="color:#f6a821" href="${origin}${item.link}">VIEW INTELLIGENCE</a>` : ''}</div>`).join('');
|
const escape = (value) =>
|
||||||
await transport.sendMail({
|
String(value).replace(
|
||||||
from: process.env.SMTP_FROM,
|
/[&<>"']/g,
|
||||||
to: recipient.email,
|
(character) =>
|
||||||
subject: `SHD.build daily notification summary · ${items.length} update${items.length === 1 ? '' : 's'}`,
|
({
|
||||||
text: items.map((item) => `${item.title}\n${item.body}\n${item.link ? `${origin}${item.link}` : ''}`).join('\n\n'),
|
'&': '&',
|
||||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD DAILY INTELLIGENCE</p><h1>${items.length} notification${items.length === 1 ? '' : 's'}</h1>${rows}</div>`
|
'<': '<',
|
||||||
});
|
'>': '>',
|
||||||
const now = new Date().toISOString();
|
'"': '"',
|
||||||
for (const item of items) await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=? AND emailed_at IS NULL')).run(now, item.id);
|
"'": ''',
|
||||||
sent += 1;
|
})[character],
|
||||||
|
);
|
||||||
|
const rows = items
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
`<div style="border-top:1px solid #34393a;padding:16px 0"><strong>${escape(item.title)}</strong><p style="color:#a5aaab">${escape(item.body)}</p>${item.link ? `<a style="color:#f6a821" href="${origin}${item.link}">VIEW INTELLIGENCE</a>` : ''}</div>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
await transport.sendMail({
|
||||||
|
from: process.env.SMTP_FROM,
|
||||||
|
to: recipient.email,
|
||||||
|
subject: `SHD.build daily notification summary · ${items.length} update${items.length === 1 ? '' : 's'}`,
|
||||||
|
text: items
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
`${item.title}\n${item.body}\n${item.link ? `${origin}${item.link}` : ''}`,
|
||||||
|
)
|
||||||
|
.join('\n\n'),
|
||||||
|
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD DAILY INTELLIGENCE</p><h1>${items.length} notification${items.length === 1 ? '' : 's'}</h1>${rows}</div>`,
|
||||||
|
});
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
for (const item of items)
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'UPDATE notifications SET emailed_at=? WHERE id=? AND emailed_at IS NULL',
|
||||||
|
)
|
||||||
|
).run(now, item.id);
|
||||||
|
sent += 1;
|
||||||
}
|
}
|
||||||
console.log(`Sent ${sent} daily notification digest${sent === 1 ? '' : 's'}.`);
|
console.log(`Sent ${sent} daily notification digest${sent === 1 ? '' : 's'}.`);
|
||||||
|
|||||||
@@ -3,67 +3,100 @@ import nodemailer from 'nodemailer';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
|
const database = await connect(
|
||||||
|
resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'),
|
||||||
|
);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const due = await (await database.prepare(`SELECT * FROM oversight_notification_queue
|
const due = await (
|
||||||
WHERE delivered_at IS NULL AND deliver_after<=? ORDER BY user_id,oversight_category,created_at`)).all(now);
|
await database.prepare(`SELECT * FROM oversight_notification_queue
|
||||||
|
WHERE delivered_at IS NULL AND deliver_after<=? ORDER BY user_id,oversight_category,created_at`)
|
||||||
|
).all(now);
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
for (const item of due) {
|
for (const item of due) {
|
||||||
const key = `${item.user_id}:${item.oversight_category}:${item.frequency}:${item.deliver_after}`;
|
const key = `${item.user_id}:${item.oversight_category}:${item.frequency}:${item.deliver_after}`;
|
||||||
if (!groups.has(key)) groups.set(key, []);
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
groups.get(key).push(item);
|
groups.get(key).push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM);
|
const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM);
|
||||||
const transport = smtpConfigured ? nodemailer.createTransport({
|
const transport = smtpConfigured
|
||||||
host: process.env.SMTP_HOST,
|
? nodemailer.createTransport({
|
||||||
port: Number(process.env.SMTP_PORT || 587),
|
host: process.env.SMTP_HOST,
|
||||||
secure: process.env.SMTP_SECURE === 'true',
|
port: Number(process.env.SMTP_PORT || 587),
|
||||||
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
requireTLS: process.env.SMTP_SECURE !== 'true',
|
auth: process.env.SMTP_USER
|
||||||
disableFileAccess: true,
|
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
|
||||||
disableUrlAccess: true
|
: undefined,
|
||||||
}) : null;
|
requireTLS: process.env.SMTP_SECURE !== 'true',
|
||||||
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
disableFileAccess: true,
|
||||||
|
disableUrlAccess: true,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(
|
||||||
|
/\/+$/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
|
|
||||||
for (const items of groups.values()) {
|
for (const items of groups.values()) {
|
||||||
const first = items[0];
|
const first = items[0];
|
||||||
const categoryLabel = first.oversight_category === 'avatars' ? 'avatar' : 'data change';
|
const categoryLabel =
|
||||||
const title = `${items.length} ${categoryLabel} review${items.length === 1 ? '' : 's'} awaiting Oversight`;
|
first.oversight_category === 'avatars' ? 'avatar' : 'data change';
|
||||||
const details = items.slice(0, 10).map((item) => `• ${item.summary}`).join('\n');
|
const title = `${items.length} ${categoryLabel} review${items.length === 1 ? '' : 's'} awaiting Oversight`;
|
||||||
const remainder = items.length > 10 ? `\n• ${items.length - 10} additional review items` : '';
|
const details = items
|
||||||
const body = `${details}${remainder}`;
|
.slice(0, 10)
|
||||||
const notificationId = randomUUID();
|
.map((item) => `• ${item.summary}`)
|
||||||
await (await database.prepare(`INSERT INTO notifications
|
.join('\n');
|
||||||
|
const remainder =
|
||||||
|
items.length > 10 ? `\n• ${items.length - 10} additional review items` : '';
|
||||||
|
const body = `${details}${remainder}`;
|
||||||
|
const notificationId = randomUUID();
|
||||||
|
await (
|
||||||
|
await database.prepare(`INSERT INTO notifications
|
||||||
(id,user_id,category,source,title,body,link,mandatory,created_at)
|
(id,user_id,category,source,title,body,link,mandatory,created_at)
|
||||||
VALUES(?,?,'isac','ISAC OVERSIGHT NETWORK',?,?,?,1,?)`)).run(
|
VALUES(?,?,'isac','ISAC OVERSIGHT NETWORK',?,?,?,1,?)`)
|
||||||
notificationId, first.user_id, title, body, first.link, now
|
).run(notificationId, first.user_id, title, body, first.link, now);
|
||||||
);
|
for (const item of items) {
|
||||||
for (const item of items) {
|
await (
|
||||||
await (await database.prepare('UPDATE oversight_notification_queue SET delivered_at=? WHERE id=? AND delivered_at IS NULL')).run(now, item.id);
|
await database.prepare(
|
||||||
}
|
'UPDATE oversight_notification_queue SET delivered_at=? WHERE id=? AND delivered_at IS NULL',
|
||||||
const preference = await (await database.prepare(
|
)
|
||||||
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'"
|
).run(now, item.id);
|
||||||
)).get(first.user_id);
|
}
|
||||||
if (transport && preference?.email_frequency === 'immediate') {
|
const preference = await (
|
||||||
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(first.user_id);
|
await database.prepare(
|
||||||
if (typeof identity?.email === 'string') {
|
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'",
|
||||||
try {
|
)
|
||||||
await transport.sendMail({
|
).get(first.user_id);
|
||||||
from: process.env.SMTP_FROM,
|
if (transport && preference?.email_frequency === 'immediate') {
|
||||||
to: identity.email,
|
const identity = await (
|
||||||
subject: `SHD.build · ${title}`,
|
await database.prepare('SELECT email FROM user_emails WHERE user_id=?')
|
||||||
text: `${body}\n\n${origin}${first.link}`,
|
).get(first.user_id);
|
||||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">ISAC OVERSIGHT NETWORK</p><h1>${title}</h1><p style="white-space:pre-line;color:#a5aaab">${body}</p><a style="color:#f6a821" href="${origin}${first.link}">OPEN OVERSIGHT QUEUE</a></div>`
|
if (typeof identity?.email === 'string') {
|
||||||
});
|
try {
|
||||||
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId);
|
await transport.sendMail({
|
||||||
} catch (error) {
|
from: process.env.SMTP_FROM,
|
||||||
console.error(`Unable to email Oversight digest ${notificationId}`, error);
|
to: identity.email,
|
||||||
}
|
subject: `SHD.build · ${title}`,
|
||||||
}
|
text: `${body}\n\n${origin}${first.link}`,
|
||||||
}
|
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">ISAC OVERSIGHT NETWORK</p><h1>${title}</h1><p style="white-space:pre-line;color:#a5aaab">${body}</p><a style="color:#f6a821" href="${origin}${first.link}">OPEN OVERSIGHT QUEUE</a></div>`,
|
||||||
delivered += items.length;
|
});
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'UPDATE notifications SET emailed_at=? WHERE id=?',
|
||||||
|
)
|
||||||
|
).run(now, notificationId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Unable to email Oversight digest ${notificationId}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delivered += items.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Delivered ${delivered} queued Oversight review alert${delivered === 1 ? '' : 's'} in ${groups.size} digest${groups.size === 1 ? '' : 's'}.`);
|
console.log(
|
||||||
|
`Delivered ${delivered} queued Oversight review alert${delivered === 1 ? '' : 's'} in ${groups.size} digest${groups.size === 1 ? '' : 's'}.`,
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const omitted = new Set(
|
||||||
|
String(process.env.npm_config_omit ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!omitted.has('dev') && process.env.NODE_ENV !== 'production') {
|
||||||
|
const { default: husky } = await import('husky');
|
||||||
|
husky();
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import opentype from 'opentype.js';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const outputDirectory = path.join(root, 'static', 'brand');
|
||||||
|
const fontDirectory = path.join(
|
||||||
|
root,
|
||||||
|
'node_modules',
|
||||||
|
'@fontsource',
|
||||||
|
'barlow',
|
||||||
|
'files',
|
||||||
|
);
|
||||||
|
const heavy = opentype.parse(
|
||||||
|
fs.readFileSync(path.join(fontDirectory, 'barlow-latin-900-normal.woff'))
|
||||||
|
.buffer,
|
||||||
|
);
|
||||||
|
const medium = opentype.parse(
|
||||||
|
fs.readFileSync(path.join(fontDirectory, 'barlow-latin-700-normal.woff'))
|
||||||
|
.buffer,
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
background: '#0a0c0d',
|
||||||
|
surface: '#111415',
|
||||||
|
border: '#303536',
|
||||||
|
grid: '#2a3031',
|
||||||
|
orange: '#f6a821',
|
||||||
|
white: '#ecebe5',
|
||||||
|
};
|
||||||
|
|
||||||
|
function trackedPath(font, value, x, baseline, size, tracking = 0) {
|
||||||
|
let cursor = x;
|
||||||
|
let data = '';
|
||||||
|
for (const character of value) {
|
||||||
|
const glyph = font.charToGlyph(character);
|
||||||
|
data += glyph.getPath(cursor, baseline, size).toPathData(2);
|
||||||
|
cursor += (glyph.advanceWidth * size) / font.unitsPerEm + tracking;
|
||||||
|
}
|
||||||
|
return { data, width: cursor - x - tracking };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emblem(x, y, size) {
|
||||||
|
const scale = size / 100;
|
||||||
|
return `
|
||||||
|
<g transform="translate(${x} ${y}) scale(${scale})">
|
||||||
|
<g fill="none" stroke="${colors.orange}" stroke-width="12" stroke-linecap="butt">
|
||||||
|
<path d="M73 20A38 38 0 0 0 15 63"/>
|
||||||
|
<path d="M85 37A38 38 0 0 1 55 88"/>
|
||||||
|
<path d="M42 87A38 38 0 0 1 18 70"/>
|
||||||
|
</g>
|
||||||
|
<rect x="72" y="23" width="10" height="10" fill="${colors.orange}"/>
|
||||||
|
<path d="M62 35H35V65H62M68 43V57" fill="none" stroke="${colors.white}" stroke-width="9" stroke-linecap="butt" stroke-linejoin="miter"/>
|
||||||
|
</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writePng(svg, filename, width, height) {
|
||||||
|
await sharp(Buffer.from(svg))
|
||||||
|
.resize(width, height, { fit: 'fill' })
|
||||||
|
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||||
|
.toFile(path.join(outputDirectory, filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
const transparentLogo = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
${emblem(72, 72, 880)}
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const serverIcon = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<rect width="1024" height="1024" fill="${colors.background}"/>
|
||||||
|
<path d="M0 192H1024M0 512H1024M0 832H1024M192 0V1024M512 0V1024M832 0V1024" stroke="${colors.grid}" stroke-width="2" opacity=".32"/>
|
||||||
|
<rect x="96" y="96" width="832" height="832" fill="none" stroke="${colors.border}" stroke-width="3"/>
|
||||||
|
${emblem(132, 132, 760)}
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const shd = trackedPath(heavy, 'SHD', 0, 0, 190, -3);
|
||||||
|
const build = trackedPath(heavy, '.build', 0, 0, 190, -2);
|
||||||
|
const descriptor = trackedPath(medium, 'RESURGENCE DATA & TOOLS', 0, 0, 43, 7);
|
||||||
|
const wordmarkWidth = shd.width + build.width;
|
||||||
|
const lockupX = 600;
|
||||||
|
const wordmarkBaseline = 590;
|
||||||
|
|
||||||
|
const banner = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="signal" cx="34%" cy="58%" r="54%">
|
||||||
|
<stop offset="0" stop-color="#2b2416"/>
|
||||||
|
<stop offset=".44" stop-color="#141617"/>
|
||||||
|
<stop offset="1" stop-color="${colors.background}"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="fade" x1="0" x2="1">
|
||||||
|
<stop stop-color="${colors.orange}" stop-opacity=".22"/>
|
||||||
|
<stop offset=".7" stop-color="${colors.orange}" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1920" height="1080" fill="${colors.background}"/>
|
||||||
|
<rect y="96" width="1920" height="984" fill="url(#signal)"/>
|
||||||
|
<path d="M0 96H1920M0 864H1920M192 96V1080M1728 96V1080" stroke="${colors.border}" stroke-width="2"/>
|
||||||
|
<path d="M0 224H1920M0 352H1920M0 480H1920M0 608H1920M0 736H1920M320 96V1080M448 96V1080M576 96V1080M704 96V1080M832 96V1080M960 96V1080M1088 96V1080M1216 96V1080M1344 96V1080M1472 96V1080M1600 96V1080" stroke="${colors.grid}" stroke-width="1" opacity=".22"/>
|
||||||
|
<path d="M192 864H1260" stroke="url(#fade)" stroke-width="3"/>
|
||||||
|
<path d="M1534 210h194v194" fill="none" stroke="${colors.orange}" stroke-width="4" opacity=".42"/>
|
||||||
|
<path d="M386 756A272 272 0 0 1 386 212" fill="none" stroke="${colors.border}" stroke-width="2"/>
|
||||||
|
${emblem(188, 314, 330)}
|
||||||
|
<g transform="translate(${lockupX} ${wordmarkBaseline})">
|
||||||
|
<path d="${shd.data}" fill="${colors.white}"/>
|
||||||
|
<path d="${build.data}" fill="${colors.orange}" transform="translate(${shd.width} 0)"/>
|
||||||
|
<rect y="34" width="${Math.max(wordmarkWidth, descriptor.width)}" height="2" fill="${colors.border}"/>
|
||||||
|
<path d="${descriptor.data}" fill="${colors.white}" transform="translate(0 98)"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(1540 820)">
|
||||||
|
<rect width="188" height="56" fill="${colors.surface}" stroke="${colors.border}" stroke-width="2"/>
|
||||||
|
<rect x="16" y="23" width="10" height="10" fill="${colors.orange}"/>
|
||||||
|
<path d="M42 28H158" stroke="${colors.orange}" stroke-width="2"/>
|
||||||
|
<path d="M166 22l8 6-8 6" fill="none" stroke="${colors.orange}" stroke-width="2"/>
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const inviteSplash = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="left-signal" cx="8%" cy="52%" r="58%">
|
||||||
|
<stop offset="0" stop-color="#3b2d15"/>
|
||||||
|
<stop offset=".38" stop-color="#171819"/>
|
||||||
|
<stop offset="1" stop-color="${colors.background}"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="right-signal" cx="94%" cy="45%" r="50%">
|
||||||
|
<stop offset="0" stop-color="#241e13"/>
|
||||||
|
<stop offset=".48" stop-color="#111415"/>
|
||||||
|
<stop offset="1" stop-color="${colors.background}" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="relay-left" x1="0" x2="1">
|
||||||
|
<stop stop-color="${colors.orange}" stop-opacity=".8"/>
|
||||||
|
<stop offset="1" stop-color="${colors.orange}" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="relay-right" x1="1" x2="0">
|
||||||
|
<stop stop-color="${colors.orange}" stop-opacity=".55"/>
|
||||||
|
<stop offset="1" stop-color="${colors.orange}" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1920" height="1080" fill="${colors.background}"/>
|
||||||
|
<rect width="1920" height="1080" fill="url(#left-signal)"/>
|
||||||
|
<rect width="1920" height="1080" fill="url(#right-signal)"/>
|
||||||
|
<path d="M0 120H1920M0 960H1920M160 0V1080M1760 0V1080" stroke="${colors.border}" stroke-width="2"/>
|
||||||
|
<path d="M0 288H1920M0 456H1920M0 624H1920M0 792H1920M328 0V1080M496 0V1080M664 0V1080M832 0V1080M1000 0V1080M1168 0V1080M1336 0V1080M1504 0V1080" stroke="${colors.grid}" stroke-width="1" opacity=".16"/>
|
||||||
|
<g opacity=".92">${emblem(-92, 224, 620)}</g>
|
||||||
|
<g opacity=".66">${emblem(1465, 326, 410)}</g>
|
||||||
|
<path d="M0 170H605" stroke="url(#relay-left)" stroke-width="4"/>
|
||||||
|
<path d="M0 910H500" stroke="url(#relay-left)" stroke-width="3"/>
|
||||||
|
<path d="M1335 188H1920" stroke="url(#relay-right)" stroke-width="3"/>
|
||||||
|
<path d="M1450 892H1920" stroke="url(#relay-right)" stroke-width="4"/>
|
||||||
|
<g fill="${colors.orange}">
|
||||||
|
<rect x="560" y="164" width="12" height="12"/>
|
||||||
|
<rect x="486" y="904" width="10" height="10"/>
|
||||||
|
<rect x="1348" y="182" width="12" height="12"/>
|
||||||
|
<rect x="1436" y="886" width="12" height="12"/>
|
||||||
|
</g>
|
||||||
|
<path d="M610 170h90M1310 188h-92M540 910h118M1400 892h-112" stroke="${colors.white}" stroke-width="2" opacity=".22"/>
|
||||||
|
<rect x="650" y="120" width="620" height="840" fill="${colors.background}" opacity=".42"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
await writePng(transparentLogo, 'shd-core-segments.png', 1024, 1024);
|
||||||
|
await writePng(serverIcon, 'shd-build-discord-icon.png', 1024, 1024);
|
||||||
|
await writePng(banner, 'shd-build-discord-banner-1920x1080.png', 1920, 1080);
|
||||||
|
await writePng(
|
||||||
|
inviteSplash,
|
||||||
|
'shd-build-discord-invite-background-1920x1080.png',
|
||||||
|
1920,
|
||||||
|
1080,
|
||||||
|
);
|
||||||
|
await sharp(
|
||||||
|
path.join(outputDirectory, 'shd-build-discord-banner-1920x1080.png'),
|
||||||
|
)
|
||||||
|
.resize(960, 540)
|
||||||
|
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||||
|
.toFile(path.join(outputDirectory, 'shd-build-discord-banner-960x540.png'));
|
||||||
|
|
||||||
|
console.log(`Rendered brand assets to ${outputDirectory}`);
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/** @typedef {'app' | 'scheduler'} RuntimeMode */
|
||||||
|
/** @typedef {Record<string, string | undefined>} RuntimeEnvironment */
|
||||||
|
|
||||||
|
const APP_REQUIRED = [
|
||||||
|
'APP_ORIGIN',
|
||||||
|
'ORIGIN',
|
||||||
|
'PASSKEY_RP_ID',
|
||||||
|
'SCHEDULER_TOKEN',
|
||||||
|
'TDR_DATABASE_PATH',
|
||||||
|
'TDR_AVATAR_STORAGE_PATH',
|
||||||
|
'TDR_GAME_ICON_STORAGE_PATH',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate environment variables after the runtime secret provider has injected them.
|
||||||
|
*
|
||||||
|
* @param {RuntimeMode} mode
|
||||||
|
* @param {RuntimeEnvironment} environment
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function validateRuntimeEnvironment(mode, environment) {
|
||||||
|
if (mode !== 'app' && mode !== 'scheduler') {
|
||||||
|
throw new TypeError(`Unsupported runtime mode: ${mode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
const get = (name) => environment[name]?.trim() ?? '';
|
||||||
|
const requireValues = (names) => {
|
||||||
|
for (const name of names) {
|
||||||
|
if (!get(name)) errors.push(`${name} is required.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'scheduler') {
|
||||||
|
requireValues(['SCHEDULER_URL', 'SCHEDULER_TOKEN']);
|
||||||
|
validateHttpUrl('SCHEDULER_URL', get('SCHEDULER_URL'), errors);
|
||||||
|
if (
|
||||||
|
get('SCHEDULER_URL') &&
|
||||||
|
get('SCHEDULER_URL') !== 'http://app:3000/api/internal/scheduler'
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
'SCHEDULER_URL must use the internal app scheduler endpoint.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validateSchedulerToken(get('SCHEDULER_TOKEN'), errors);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
requireValues(APP_REQUIRED);
|
||||||
|
validateSchedulerToken(get('SCHEDULER_TOKEN'), errors);
|
||||||
|
|
||||||
|
const appOrigin = validateOrigin('APP_ORIGIN', get('APP_ORIGIN'), errors);
|
||||||
|
const adapterOrigin = validateOrigin('ORIGIN', get('ORIGIN'), errors);
|
||||||
|
if (
|
||||||
|
appOrigin &&
|
||||||
|
adapterOrigin &&
|
||||||
|
appOrigin.hostname !== adapterOrigin.hostname
|
||||||
|
) {
|
||||||
|
errors.push('APP_ORIGIN and ORIGIN must use the same hostname.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rpId = get('PASSKEY_RP_ID').toLowerCase();
|
||||||
|
if (rpId && !isHostname(rpId)) {
|
||||||
|
errors.push('PASSKEY_RP_ID must be a hostname.');
|
||||||
|
} else if (
|
||||||
|
appOrigin &&
|
||||||
|
rpId &&
|
||||||
|
appOrigin.hostname !== rpId &&
|
||||||
|
!appOrigin.hostname.endsWith(`.${rpId}`)
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
'PASSKEY_RP_ID must equal APP_ORIGIN hostname or be its registrable parent domain.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
requirePair(
|
||||||
|
'DISCORD_CLIENT_ID',
|
||||||
|
get('DISCORD_CLIENT_ID'),
|
||||||
|
'DISCORD_CLIENT_SECRET',
|
||||||
|
get('DISCORD_CLIENT_SECRET'),
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
requirePair(
|
||||||
|
'TURNSTILE_SITE_KEY',
|
||||||
|
get('TURNSTILE_SITE_KEY'),
|
||||||
|
'TURNSTILE_SECRET_KEY',
|
||||||
|
get('TURNSTILE_SECRET_KEY'),
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
|
||||||
|
const smtpEnabled = [
|
||||||
|
'SMTP_HOST',
|
||||||
|
'SMTP_FROM',
|
||||||
|
'SMTP_USER',
|
||||||
|
'SMTP_PASSWORD',
|
||||||
|
].some((name) => Boolean(get(name)));
|
||||||
|
if (smtpEnabled) {
|
||||||
|
requirePair(
|
||||||
|
'SMTP_HOST',
|
||||||
|
get('SMTP_HOST'),
|
||||||
|
'SMTP_FROM',
|
||||||
|
get('SMTP_FROM'),
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
requirePair(
|
||||||
|
'SMTP_USER',
|
||||||
|
get('SMTP_USER'),
|
||||||
|
'SMTP_PASSWORD',
|
||||||
|
get('SMTP_PASSWORD'),
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const smtpPort = get('SMTP_PORT');
|
||||||
|
if (
|
||||||
|
smtpPort &&
|
||||||
|
(!/^\d+$/.test(smtpPort) ||
|
||||||
|
Number(smtpPort) < 1 ||
|
||||||
|
Number(smtpPort) > 65535)
|
||||||
|
) {
|
||||||
|
errors.push('SMTP_PORT must be an integer from 1 through 65535.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const smtpSecure = get('SMTP_SECURE');
|
||||||
|
if (smtpSecure && !['true', 'false'].includes(smtpSecure)) {
|
||||||
|
errors.push('SMTP_SECURE must be true or false.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const discordRedirect = validateHttpUrl(
|
||||||
|
'DISCORD_REDIRECT_URI',
|
||||||
|
get('DISCORD_REDIRECT_URI'),
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
appOrigin &&
|
||||||
|
discordRedirect &&
|
||||||
|
(discordRedirect.hostname !== appOrigin.hostname ||
|
||||||
|
discordRedirect.pathname !== '/auth/discord/callback')
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
'DISCORD_REDIRECT_URI must use APP_ORIGIN hostname and /auth/discord/callback.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of [
|
||||||
|
'TDR_DATABASE_PATH',
|
||||||
|
'TDR_AVATAR_STORAGE_PATH',
|
||||||
|
'TDR_GAME_ICON_STORAGE_PATH',
|
||||||
|
]) {
|
||||||
|
if (get(name) && !get(name).startsWith('/')) {
|
||||||
|
errors.push(`${name} must be an absolute hosted path.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} firstName
|
||||||
|
* @param {string} firstValue
|
||||||
|
* @param {string} secondName
|
||||||
|
* @param {string} secondValue
|
||||||
|
* @param {string[]} errors
|
||||||
|
*/
|
||||||
|
function requirePair(firstName, firstValue, secondName, secondValue, errors) {
|
||||||
|
if (Boolean(firstValue) !== Boolean(secondValue)) {
|
||||||
|
errors.push(`${firstName} and ${secondName} must be configured together.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @param {string} value
|
||||||
|
* @param {string[]} errors
|
||||||
|
* @returns {URL | undefined}
|
||||||
|
*/
|
||||||
|
function validateHttpUrl(name, value, errors) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname) {
|
||||||
|
throw new TypeError('not an HTTP URL');
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
} catch {
|
||||||
|
errors.push(`${name} must be a valid HTTP or HTTPS URL.`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @param {string} value
|
||||||
|
* @param {string[]} errors
|
||||||
|
* @returns {URL | undefined}
|
||||||
|
*/
|
||||||
|
function validateOrigin(name, value, errors) {
|
||||||
|
const url = validateHttpUrl(name, value, errors);
|
||||||
|
if (!url) return undefined;
|
||||||
|
if (
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
url.pathname !== '/' ||
|
||||||
|
url.search ||
|
||||||
|
url.hash
|
||||||
|
) {
|
||||||
|
errors.push(`${name} must contain only a URL origin.`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} value */
|
||||||
|
function isHostname(value) {
|
||||||
|
if (value === 'localhost') return true;
|
||||||
|
if (value.length > 253 || !value.includes('.')) return false;
|
||||||
|
return value
|
||||||
|
.split('.')
|
||||||
|
.every(
|
||||||
|
(label) =>
|
||||||
|
label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} value @param {string[]} errors */
|
||||||
|
function validateSchedulerToken(value, errors) {
|
||||||
|
if (value && (value.length < 32 || /\s/.test(value))) {
|
||||||
|
errors.push(
|
||||||
|
'SCHEDULER_TOKEN must contain at least 32 non-whitespace characters.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { validateRuntimeEnvironment } from './runtime-environment.js';
|
||||||
|
|
||||||
|
const validAppEnvironment = {
|
||||||
|
APP_ORIGIN: 'https://stage.shd.build',
|
||||||
|
ORIGIN: 'https://stage.shd.build',
|
||||||
|
PASSKEY_RP_ID: 'shd.build',
|
||||||
|
SCHEDULER_TOKEN: 'scheduler-secret-with-32-characters',
|
||||||
|
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
|
||||||
|
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
||||||
|
TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('validateRuntimeEnvironment', () => {
|
||||||
|
it('accepts the hosted app contract without optional providers', () => {
|
||||||
|
expect(validateRuntimeEnvironment('app', validAppEnvironment)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports every missing required app value', () => {
|
||||||
|
expect(validateRuntimeEnvironment('app', {})).toEqual([
|
||||||
|
'APP_ORIGIN is required.',
|
||||||
|
'ORIGIN is required.',
|
||||||
|
'PASSKEY_RP_ID is required.',
|
||||||
|
'SCHEDULER_TOKEN is required.',
|
||||||
|
'TDR_DATABASE_PATH is required.',
|
||||||
|
'TDR_AVATAR_STORAGE_PATH is required.',
|
||||||
|
'TDR_GAME_ICON_STORAGE_PATH is required.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates canonical origins and passkey hostname scope', () => {
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
APP_ORIGIN: 'https://stage.shd.build/sign-in',
|
||||||
|
}),
|
||||||
|
).toContain('APP_ORIGIN must contain only a URL origin.');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
PASSKEY_RP_ID: 'example.com',
|
||||||
|
}),
|
||||||
|
).toContain(
|
||||||
|
'PASSKEY_RP_ID must equal APP_ORIGIN hostname or be its registrable parent domain.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects mismatched app and adapter hostnames', () => {
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
ORIGIN: 'https://other.shd.build',
|
||||||
|
}),
|
||||||
|
).toContain('APP_ORIGIN and ORIGIN must use the same hostname.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires optional integration credentials only as pairs', () => {
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
DISCORD_CLIENT_ID: 'client-id',
|
||||||
|
TURNSTILE_SECRET_KEY: 'secret',
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
'DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET must be configured together.',
|
||||||
|
'TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY must be configured together.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows SMTP defaults without enabling email and validates enabled SMTP', () => {
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
SMTP_PORT: '587',
|
||||||
|
SMTP_SECURE: 'false',
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
SMTP_HOST: 'smtp.example.com',
|
||||||
|
SMTP_USER: 'agent',
|
||||||
|
SMTP_PORT: '70000',
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
'SMTP_HOST and SMTP_FROM must be configured together.',
|
||||||
|
'SMTP_USER and SMTP_PASSWORD must be configured together.',
|
||||||
|
'SMTP_PORT must be an integer from 1 through 65535.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates optional redirect URLs when supplied', () => {
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
DISCORD_REDIRECT_URI: 'ftp://stage.shd.build/callback',
|
||||||
|
}),
|
||||||
|
).toContain('DISCORD_REDIRECT_URI must be a valid HTTP or HTTPS URL.');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
DISCORD_REDIRECT_URI: 'https://dev.shd.build/auth/discord/callback',
|
||||||
|
}),
|
||||||
|
).toContain(
|
||||||
|
'DISCORD_REDIRECT_URI must use APP_ORIGIN hostname and /auth/discord/callback.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates hosted paths and SMTP TLS configuration', () => {
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
SMTP_SECURE: 'yes',
|
||||||
|
TDR_DATABASE_PATH: './data/staging.db',
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
'SMTP_SECURE must be true or false.',
|
||||||
|
'TDR_DATABASE_PATH must be an absolute hosted path.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a configured scheduler and rejects missing or invalid values', () => {
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('scheduler', {
|
||||||
|
SCHEDULER_URL: 'http://app:3000/api/internal/scheduler',
|
||||||
|
SCHEDULER_TOKEN: 'scheduler-secret-with-32-characters',
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('scheduler', {
|
||||||
|
SCHEDULER_URL: 'not a URL',
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
'SCHEDULER_TOKEN is required.',
|
||||||
|
'SCHEDULER_URL must be a valid HTTP or HTTPS URL.',
|
||||||
|
'SCHEDULER_URL must use the internal app scheduler endpoint.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a strong scheduler token and fixed internal endpoint', () => {
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('scheduler', {
|
||||||
|
SCHEDULER_URL: 'https://example.com/api/internal/scheduler',
|
||||||
|
SCHEDULER_TOKEN: 'too-short',
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
'SCHEDULER_URL must use the internal app scheduler endpoint.',
|
||||||
|
'SCHEDULER_TOKEN must contain at least 32 non-whitespace characters.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported runtime modes', () => {
|
||||||
|
expect(() =>
|
||||||
|
validateRuntimeEnvironment('worker' as 'app', validAppEnvironment),
|
||||||
|
).toThrow('Unsupported runtime mode: worker');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { readFile, unlink } from 'node:fs/promises';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { validateRuntimeEnvironment } from './runtime-environment.js';
|
||||||
|
|
||||||
|
const APP_SECRET_NAMES = new Set([
|
||||||
|
'APP_ORIGIN',
|
||||||
|
'DISCORD_CLIENT_ID',
|
||||||
|
'DISCORD_CLIENT_SECRET',
|
||||||
|
'DISCORD_REDIRECT_URI',
|
||||||
|
'OPENAI_MECHANIC_API_KEY',
|
||||||
|
'OPENAI_MECHANIC_MODEL',
|
||||||
|
'OPENAI_MECHANIC_REASONING_EFFORT',
|
||||||
|
'OPENAI_MODERATION_API_KEY',
|
||||||
|
'MFA_TOTP_ENCRYPTION_KEYS',
|
||||||
|
'ORIGIN',
|
||||||
|
'PASSKEY_RP_ID',
|
||||||
|
'REMOVE_BG_API_KEY',
|
||||||
|
'REMOVE_BG_CREDIT_CHECK_HOURS',
|
||||||
|
'REMOVE_BG_LOW_CREDIT_THRESHOLD',
|
||||||
|
'REMOVE_BG_LOW_FREE_CALL_THRESHOLD',
|
||||||
|
'SMTP_FROM',
|
||||||
|
'SMTP_HOST',
|
||||||
|
'SMTP_PASSWORD',
|
||||||
|
'SMTP_PORT',
|
||||||
|
'SMTP_SECURE',
|
||||||
|
'SMTP_USER',
|
||||||
|
'TDR_AVATAR_STORAGE_PATH',
|
||||||
|
'TDR_DATABASE_PATH',
|
||||||
|
'TDR_GAME_ICON_STORAGE_PATH',
|
||||||
|
'TURNSTILE_SECRET_KEY',
|
||||||
|
'TURNSTILE_SITE_KEY',
|
||||||
|
]);
|
||||||
|
const SHARED_SECRET_NAMES = new Set(['SCHEDULER_TOKEN']);
|
||||||
|
const REMOVED_ENVIRONMENT_NAMES = [
|
||||||
|
'BASH_ENV',
|
||||||
|
'CDPATH',
|
||||||
|
'ENV',
|
||||||
|
'IFS',
|
||||||
|
'INFISICAL_ENVIRONMENT',
|
||||||
|
'INFISICAL_PROJECT_ID',
|
||||||
|
'INFISICAL_RUNTIME_SCOPE',
|
||||||
|
'INFISICAL_TOKEN',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE',
|
||||||
|
'LD_LIBRARY_PATH',
|
||||||
|
'LD_PRELOAD',
|
||||||
|
'NODE_OPTIONS',
|
||||||
|
'NODE_PATH',
|
||||||
|
'PYTHONPATH',
|
||||||
|
'TOKEN',
|
||||||
|
];
|
||||||
|
|
||||||
|
const [runtimeScope, environment, buildNumber = '0', commitSha = '', ...rest] =
|
||||||
|
process.argv.slice(2);
|
||||||
|
const separatorIndex = rest.indexOf('--');
|
||||||
|
if (
|
||||||
|
!['app', 'scheduler'].includes(runtimeScope) ||
|
||||||
|
!['staging', 'prod'].includes(environment) ||
|
||||||
|
separatorIndex < 1
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
'Usage: runtime-launcher.mjs <app|scheduler> <staging|prod> <build> <sha> <secret-files...> -- <command...>',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretFiles = rest.slice(0, separatorIndex);
|
||||||
|
const requestedCommand = rest.slice(separatorIndex + 1);
|
||||||
|
if (requestedCommand.length === 0) {
|
||||||
|
console.error('A runtime command is required.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedFileCount = runtimeScope === 'app' ? 2 : 1;
|
||||||
|
if (secretFiles.length !== expectedFileCount) {
|
||||||
|
console.error(
|
||||||
|
`${runtimeScope} requires ${expectedFileCount} secret export file(s).`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secrets = {};
|
||||||
|
try {
|
||||||
|
await loadSecretFile(
|
||||||
|
secretFiles[0],
|
||||||
|
runtimeScope === 'app' ? APP_SECRET_NAMES : SHARED_SECRET_NAMES,
|
||||||
|
secrets,
|
||||||
|
);
|
||||||
|
if (runtimeScope === 'app') {
|
||||||
|
await loadSecretFile(secretFiles[1], SHARED_SECRET_NAMES, secrets);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
const deletionResults = await Promise.allSettled(
|
||||||
|
secretFiles.map((file) => unlink(file)),
|
||||||
|
);
|
||||||
|
const undeletedFiles = secretFiles.filter(
|
||||||
|
(_, index) => deletionResults[index].status === 'rejected',
|
||||||
|
);
|
||||||
|
if (undeletedFiles.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to delete Infisical secret export: ${undeletedFiles.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const childEnvironment = { ...process.env, ...secrets };
|
||||||
|
for (const name of REMOVED_ENVIRONMENT_NAMES) delete childEnvironment[name];
|
||||||
|
Object.assign(
|
||||||
|
childEnvironment,
|
||||||
|
trustedRuntimeEnvironment(runtimeScope, environment, buildNumber, commitSha),
|
||||||
|
);
|
||||||
|
|
||||||
|
const errors = validateRuntimeEnvironment(runtimeScope, childEnvironment);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error(
|
||||||
|
`Invalid ${runtimeScope} runtime environment:\n${errors.map((error) => `- ${error}`).join('\n')}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.umask(0o007);
|
||||||
|
|
||||||
|
if (requestedCommand[0] === 'serve') {
|
||||||
|
await runAsNode(['node', 'scripts/db-migrate.mjs'], childEnvironment);
|
||||||
|
await runAsNode(['node', 'build'], childEnvironment);
|
||||||
|
} else if (requestedCommand[0] === 'scheduler') {
|
||||||
|
await runAsNode(['node', 'scripts/scheduler-client.mjs'], childEnvironment);
|
||||||
|
} else {
|
||||||
|
await runAsNode(requestedCommand, childEnvironment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSecretFile(file, allowedNames, target) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = JSON.parse(await readFile(file, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
throw new Error('Infisical returned an unreadable secret export.');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(entries)) {
|
||||||
|
throw new Error('Infisical secret export must be an array.');
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const name = entry?.key;
|
||||||
|
const value = entry?.value;
|
||||||
|
if (typeof name !== 'string' || typeof value !== 'string') {
|
||||||
|
throw new Error('Infisical secret export contains an invalid entry.');
|
||||||
|
}
|
||||||
|
if (!allowedNames.has(name)) {
|
||||||
|
throw new Error(
|
||||||
|
`Infisical secret ${name} is not allowed in this runtime scope.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Object.hasOwn(target, name)) {
|
||||||
|
throw new Error(`Infisical secret ${name} was exported more than once.`);
|
||||||
|
}
|
||||||
|
target[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trustedRuntimeEnvironment(
|
||||||
|
scope,
|
||||||
|
targetEnvironment,
|
||||||
|
release,
|
||||||
|
revision,
|
||||||
|
) {
|
||||||
|
const common = {
|
||||||
|
BODY_SIZE_LIMIT: '6M',
|
||||||
|
HOST: '0.0.0.0',
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: '3000',
|
||||||
|
SHD_BUILD_NUMBER: release,
|
||||||
|
SHD_COMMIT_SHA: revision,
|
||||||
|
};
|
||||||
|
if (scope === 'scheduler') {
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
SCHEDULER_URL: 'http://app:3000/api/internal/scheduler',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (targetEnvironment === 'staging') {
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
APP_ORIGIN: 'https://stage.shd.build',
|
||||||
|
DISCORD_REDIRECT_URI: 'https://stage.shd.build/auth/discord/callback',
|
||||||
|
ORIGIN: 'https://stage.shd.build',
|
||||||
|
PASSKEY_RP_ID: 'shd.build',
|
||||||
|
SKIP_DEFAULT_USERS: 'true',
|
||||||
|
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
||||||
|
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
|
||||||
|
TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons',
|
||||||
|
UBISOFT_NEWS_IMPORT_ENABLED: 'false',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
APP_ORIGIN: 'https://shd.build',
|
||||||
|
DISCORD_REDIRECT_URI: 'https://shd.build/auth/discord/callback',
|
||||||
|
ORIGIN: 'https://shd.build',
|
||||||
|
PASSKEY_RP_ID: 'shd.build',
|
||||||
|
SKIP_DEFAULT_USERS: 'true',
|
||||||
|
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
||||||
|
TDR_DATABASE_PATH: '/app/data/shd-build-production.db',
|
||||||
|
TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons',
|
||||||
|
UBISOFT_NEWS_IMPORT_ENABLED: 'true',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAsNode(command, environmentVariables) {
|
||||||
|
const usesPrivilegeDrop =
|
||||||
|
typeof process.getuid === 'function' && process.getuid() === 0;
|
||||||
|
const executable = usesPrivilegeDrop ? 'gosu' : command[0];
|
||||||
|
const args = usesPrivilegeDrop
|
||||||
|
? ['shd-runtime', ...command]
|
||||||
|
: command.slice(1);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(executable, args, {
|
||||||
|
env: environmentVariables,
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
const signalHandlers = new Map();
|
||||||
|
for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
|
||||||
|
const handler = () => child.kill(signal);
|
||||||
|
signalHandlers.set(signal, handler);
|
||||||
|
process.once(signal, handler);
|
||||||
|
}
|
||||||
|
const cleanup = () => {
|
||||||
|
for (const [signal, handler] of signalHandlers) {
|
||||||
|
process.off(signal, handler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
child.once('error', (error) => {
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
child.once('exit', (code, signal) => {
|
||||||
|
cleanup();
|
||||||
|
if (signal) {
|
||||||
|
process.kill(process.pid, signal);
|
||||||
|
} else if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Runtime command exited with status ${code ?? 1}.`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const endpoint = process.env.SCHEDULER_URL;
|
||||||
|
const token = process.env.SCHEDULER_TOKEN;
|
||||||
|
if (!endpoint || !token)
|
||||||
|
throw new Error('SCHEDULER_URL and SCHEDULER_TOKEN are required.');
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`Scheduler endpoint returned HTTP ${response.status}.`);
|
||||||
|
console.log(`Scheduler tick: ${JSON.stringify(await response.json())}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Scheduler tick failed', error);
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 60_000));
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ import { resolve } from 'node:path';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { initializeDefaultUsers } from './default-users.mjs';
|
import { initializeDefaultUsers } from './default-users.mjs';
|
||||||
|
|
||||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
|
);
|
||||||
const database = await connect(databasePath);
|
const database = await connect(databasePath);
|
||||||
const resetPasswords = process.argv.includes('--reset-passwords');
|
const resetPasswords = process.argv.includes('--reset-passwords');
|
||||||
|
|
||||||
|
|||||||
@@ -3,22 +3,63 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
const targetName = process.argv[2] || 'Aewyn';
|
const targetName = process.argv[2] || 'Aewyn';
|
||||||
const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
|
const database = await connect(
|
||||||
const user = await (await database.prepare(`SELECT users.id,COALESCE(user_profiles.display_name,users.username) AS display_name
|
resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'),
|
||||||
|
);
|
||||||
|
const user = await (
|
||||||
|
await database.prepare(`SELECT users.id,COALESCE(user_profiles.display_name,users.username) AS display_name
|
||||||
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||||
WHERE users.login_username=? COLLATE NOCASE OR users.username=? COLLATE NOCASE OR user_profiles.display_name=? COLLATE NOCASE
|
WHERE users.login_username=? COLLATE NOCASE OR users.username=? COLLATE NOCASE OR user_profiles.display_name=? COLLATE NOCASE
|
||||||
LIMIT 1`)).get(targetName, targetName, targetName);
|
LIMIT 1`)
|
||||||
|
).get(targetName, targetName, targetName);
|
||||||
if (!user) throw new Error(`No user matched ${targetName}.`);
|
if (!user) throw new Error(`No user matched ${targetName}.`);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const samples = [
|
const samples = [
|
||||||
['isac', 'ISAC AVATAR OVERSIGHT', '[TEST] Custom avatar removed', 'Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: The image contained unrelated promotional text.', '/account#avatar', 1],
|
[
|
||||||
['contributions', 'ISAC DATA OVERSIGHT', '[TEST] Contribution approved', 'Quick Draw brand-set data was approved and deployed to the live builder registry.', '/isac/brands', 1],
|
'isac',
|
||||||
['contributions', 'ISAC DATA OVERSIGHT', '[TEST] Contribution needs revision', 'A talent update was not approved. Oversight reason: Tier II effect value does not match the supplied game evidence.', '/isac/talents', 1],
|
'ISAC AVATAR OVERSIGHT',
|
||||||
['comments', 'SHD COMMUNITY', '[TEST] New comment on your build', 'An agent commented on “First Wave Vector Critical Build”: This setup worked really well in Legendary missions.', '/builds', 0]
|
'[TEST] Custom avatar removed',
|
||||||
|
'Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: The image contained unrelated promotional text.',
|
||||||
|
'/account#avatar',
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'contributions',
|
||||||
|
'ISAC DATA OVERSIGHT',
|
||||||
|
'[TEST] Contribution approved',
|
||||||
|
'Quick Draw brand-set data was approved and deployed to the live builder registry.',
|
||||||
|
'/isac/brands',
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'contributions',
|
||||||
|
'ISAC DATA OVERSIGHT',
|
||||||
|
'[TEST] Contribution needs revision',
|
||||||
|
'A talent update was not approved. Oversight reason: Tier II effect value does not match the supplied game evidence.',
|
||||||
|
'/isac/talents',
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'comments',
|
||||||
|
'SHD COMMUNITY',
|
||||||
|
'[TEST] New comment on your build',
|
||||||
|
'An agent commented on “First Wave Vector Critical Build”: This setup worked really well in Legendary missions.',
|
||||||
|
'/builds',
|
||||||
|
0,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
for (const [index, sample] of samples.entries()) {
|
for (const [index, sample] of samples.entries()) {
|
||||||
await (await database.prepare(`INSERT INTO notifications
|
await (
|
||||||
|
await database.prepare(`INSERT INTO notifications
|
||||||
(id,user_id,category,source,title,body,link,mandatory,created_at)
|
(id,user_id,category,source,title,body,link,mandatory,created_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?)`)).run(randomUUID(), user.id, ...sample, new Date(now - index * 60_000).toISOString());
|
VALUES(?,?,?,?,?,?,?,?,?)`)
|
||||||
|
).run(
|
||||||
|
randomUUID(),
|
||||||
|
user.id,
|
||||||
|
...sample,
|
||||||
|
new Date(now - index * 60_000).toISOString(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
console.log(`Added ${samples.length} test notifications for ${user.display_name}.`);
|
console.log(
|
||||||
|
`Added ${samples.length} test notifications for ${user.display_name}.`,
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const outputArgument = process.argv[2];
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const outputPath = resolve(
|
||||||
|
outputArgument || `./data/backups/shd-stage-bootstrap-${timestamp}.json`,
|
||||||
|
);
|
||||||
|
const temporaryBackupPath = `${outputPath}.database.json`;
|
||||||
|
const databaseBackup = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
['scripts/db-backup.mjs', temporaryBackupPath],
|
||||||
|
{
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (databaseBackup.error) throw databaseBackup.error;
|
||||||
|
if (databaseBackup.status !== 0)
|
||||||
|
throw new Error(
|
||||||
|
'Database backup failed; no staging bootstrap bundle was created.',
|
||||||
|
);
|
||||||
|
|
||||||
|
async function collectFiles(directory, pattern) {
|
||||||
|
try {
|
||||||
|
const names = (await readdir(directory))
|
||||||
|
.filter((name) => pattern.test(name))
|
||||||
|
.sort();
|
||||||
|
return await Promise.all(
|
||||||
|
names.map(async (name) => ({
|
||||||
|
name,
|
||||||
|
contentBase64: (await readFile(join(directory, name))).toString(
|
||||||
|
'base64',
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const database = JSON.parse(await readFile(temporaryBackupPath, 'utf8'));
|
||||||
|
const avatars = await collectFiles(
|
||||||
|
resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'),
|
||||||
|
/^[0-9a-f-]{36}\.webp$/i,
|
||||||
|
);
|
||||||
|
const gameIcons = await collectFiles(
|
||||||
|
resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'),
|
||||||
|
/^[a-z_]+-[0-9a-f-]{36}\.webp$/i,
|
||||||
|
);
|
||||||
|
const bundle = {
|
||||||
|
format: 'shd-build-stage-bootstrap',
|
||||||
|
version: 1,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
database,
|
||||||
|
files: { avatars, gameIcons },
|
||||||
|
};
|
||||||
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
|
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
flag: 'wx',
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`Staging bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} game-data icons.`,
|
||||||
|
);
|
||||||
|
console.log(`Bootstrap bundle: ${outputPath}`);
|
||||||
|
} finally {
|
||||||
|
await rm(temporaryBackupPath, { force: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { connect } from '@tursodatabase/database';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const bundleArgument = process.argv
|
||||||
|
.slice(2)
|
||||||
|
.find((argument) => !argument.startsWith('--'));
|
||||||
|
if (
|
||||||
|
!bundleArgument ||
|
||||||
|
!process.argv.includes('--confirm-empty-staging-replace') ||
|
||||||
|
process.env.ALLOW_STAGING_BOOTSTRAP !== 'true'
|
||||||
|
) {
|
||||||
|
console.error('Staging bootstrap requires ALLOW_STAGING_BOOTSTRAP=true and:');
|
||||||
|
console.error(
|
||||||
|
'node scripts/stage-bootstrap-import.mjs <bundle.json> --confirm-empty-staging-replace',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundlePath = resolve(bundleArgument);
|
||||||
|
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
||||||
|
if (
|
||||||
|
bundle?.format !== 'shd-build-stage-bootstrap' ||
|
||||||
|
bundle?.version !== 1 ||
|
||||||
|
bundle.database?.format !== 'shd-build-database-backup' ||
|
||||||
|
bundle.database?.version !== 1 ||
|
||||||
|
!Array.isArray(bundle.database.tables) ||
|
||||||
|
!Array.isArray(bundle.files?.avatars) ||
|
||||||
|
!Array.isArray(bundle.files?.gameIcons)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The selected file is not a supported SHD.build staging bootstrap bundle.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const file of bundle.files.avatars) {
|
||||||
|
if (
|
||||||
|
typeof file.name !== 'string' ||
|
||||||
|
!/^[0-9a-f-]{36}\.webp$/i.test(file.name) ||
|
||||||
|
typeof file.contentBase64 !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('The bootstrap bundle contains an invalid avatar file.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const file of bundle.files.gameIcons) {
|
||||||
|
if (
|
||||||
|
typeof file.name !== 'string' ||
|
||||||
|
!/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(file.name) ||
|
||||||
|
typeof file.contentBase64 !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The bootstrap bundle contains an invalid game-data icon file.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || './data/shd-build-staging.db',
|
||||||
|
);
|
||||||
|
const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
if (safetyBackup.error) throw safetyBackup.error;
|
||||||
|
if (safetyBackup.status !== 0)
|
||||||
|
throw new Error('Staging safety backup failed; the target was not changed.');
|
||||||
|
|
||||||
|
const database = await connect(databasePath);
|
||||||
|
for (const tableName of [
|
||||||
|
'users',
|
||||||
|
'builds',
|
||||||
|
'audit_log',
|
||||||
|
'data_change_proposals',
|
||||||
|
]) {
|
||||||
|
const row = await (
|
||||||
|
await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`)
|
||||||
|
).get();
|
||||||
|
if (Number(row?.count ?? 0) > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Staging bootstrap refused: target table "${tableName}" is not empty.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTables = await (
|
||||||
|
await database.prepare(`
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||||
|
ORDER BY rowid
|
||||||
|
`)
|
||||||
|
).all();
|
||||||
|
const targetNames = new Set(targetTables.map((row) => String(row.name)));
|
||||||
|
const sourceNames = new Set(
|
||||||
|
bundle.database.tables.map((table) => String(table.name)),
|
||||||
|
);
|
||||||
|
for (const row of targetTables) {
|
||||||
|
if (!sourceNames.has(String(row.name))) {
|
||||||
|
throw new Error(
|
||||||
|
`Bootstrap snapshot is missing target-schema table "${String(row.name)}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sourceOnlyTables = bundle.database.tables
|
||||||
|
.map((table) => String(table.name))
|
||||||
|
.filter((name) => !targetNames.has(name));
|
||||||
|
if (sourceOnlyTables.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const quoteIdentifier = (identifier) =>
|
||||||
|
`"${String(identifier).replaceAll('"', '""')}"`;
|
||||||
|
let restoredRows = 0;
|
||||||
|
await database.exec('PRAGMA foreign_keys=OFF');
|
||||||
|
await database.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
for (const row of [...targetTables].reverse()) {
|
||||||
|
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
|
||||||
|
}
|
||||||
|
for (const table of bundle.database.tables) {
|
||||||
|
for (const row of table.rows) {
|
||||||
|
const columns = Object.keys(row);
|
||||||
|
if (!columns.length) continue;
|
||||||
|
const insert = await database.prepare(
|
||||||
|
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
|
||||||
|
VALUES (${columns.map(() => '?').join(', ')})`,
|
||||||
|
);
|
||||||
|
await insert.run(...columns.map((column) => row[column]));
|
||||||
|
restoredRows++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await database.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await database.exec('PRAGMA foreign_keys=ON');
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarDirectory = resolve(
|
||||||
|
process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars',
|
||||||
|
);
|
||||||
|
const gameIconDirectory = resolve(
|
||||||
|
process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons',
|
||||||
|
);
|
||||||
|
await mkdir(avatarDirectory, { recursive: true });
|
||||||
|
await mkdir(gameIconDirectory, { recursive: true });
|
||||||
|
for (const file of bundle.files.avatars) {
|
||||||
|
await writeFile(
|
||||||
|
join(avatarDirectory, file.name),
|
||||||
|
Buffer.from(file.contentBase64, 'base64'),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const file of bundle.files.gameIcons) {
|
||||||
|
await writeFile(
|
||||||
|
join(gameIconDirectory, file.name),
|
||||||
|
Buffer.from(file.contentBase64, 'base64'),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Bootstrapped staging with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} game-data icons.`,
|
||||||
|
);
|
||||||
|
console.log(`Source bundle: ${bundlePath}`);
|
||||||
@@ -3,64 +3,102 @@ import nodemailer from 'nodemailer';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
|
const database = await connect(
|
||||||
|
resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'),
|
||||||
|
);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const due = await (await database.prepare(`SELECT delivery.id AS delivery_id,delivery.user_id,message.*
|
const due = await (
|
||||||
|
await database.prepare(`SELECT delivery.id AS delivery_id,delivery.user_id,message.*
|
||||||
FROM automated_system_message_deliveries delivery
|
FROM automated_system_message_deliveries delivery
|
||||||
JOIN automated_system_messages message ON message.id=delivery.message_id
|
JOIN automated_system_messages message ON message.id=delivery.message_id
|
||||||
WHERE delivery.delivered_at IS NULL AND delivery.scheduled_for<=? AND message.enabled=1
|
WHERE delivery.delivered_at IS NULL AND delivery.scheduled_for<=? AND message.enabled=1
|
||||||
ORDER BY delivery.scheduled_for LIMIT 250`)).all(now);
|
ORDER BY delivery.scheduled_for LIMIT 250`)
|
||||||
|
).all(now);
|
||||||
const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM);
|
const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM);
|
||||||
const transport = smtpConfigured ? nodemailer.createTransport({
|
const transport = smtpConfigured
|
||||||
host: process.env.SMTP_HOST,
|
? nodemailer.createTransport({
|
||||||
port: Number(process.env.SMTP_PORT || 587),
|
host: process.env.SMTP_HOST,
|
||||||
secure: process.env.SMTP_SECURE === 'true',
|
port: Number(process.env.SMTP_PORT || 587),
|
||||||
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
requireTLS: process.env.SMTP_SECURE !== 'true',
|
auth: process.env.SMTP_USER
|
||||||
disableFileAccess: true,
|
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
|
||||||
disableUrlAccess: true
|
: undefined,
|
||||||
}) : null;
|
requireTLS: process.env.SMTP_SECURE !== 'true',
|
||||||
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
disableFileAccess: true,
|
||||||
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({
|
disableUrlAccess: true,
|
||||||
'&': '&',
|
})
|
||||||
'<': '<',
|
: null;
|
||||||
'>': '>',
|
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(
|
||||||
'"': '"',
|
/\/+$/,
|
||||||
"'": '''
|
'',
|
||||||
})[character]);
|
);
|
||||||
|
const escapeHtml = (value) =>
|
||||||
|
String(value).replace(
|
||||||
|
/[&<>"']/g,
|
||||||
|
(character) =>
|
||||||
|
({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
})[character],
|
||||||
|
);
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
|
|
||||||
for (const message of due) {
|
for (const message of due) {
|
||||||
const notificationId = randomUUID();
|
const notificationId = randomUUID();
|
||||||
await (await database.prepare(`INSERT INTO notifications
|
await (
|
||||||
|
await database.prepare(`INSERT INTO notifications
|
||||||
(id,user_id,category,source,title,body,link,actions_json,mandatory,created_at)
|
(id,user_id,category,source,title,body,link,actions_json,mandatory,created_at)
|
||||||
VALUES(?,?,'isac',?,?,?,?,?,1,?)`)).run(
|
VALUES(?,?,'isac',?,?,?,?,?,1,?)`)
|
||||||
notificationId, message.user_id, message.source, message.title, message.body,
|
).run(
|
||||||
message.link ?? null, message.actions_json ?? null, now
|
notificationId,
|
||||||
);
|
message.user_id,
|
||||||
await (await database.prepare(`UPDATE automated_system_message_deliveries
|
message.source,
|
||||||
SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`))
|
message.title,
|
||||||
.run(now, notificationId, message.delivery_id);
|
message.body,
|
||||||
const preference = await (await database.prepare(
|
message.link ?? null,
|
||||||
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'"
|
message.actions_json ?? null,
|
||||||
)).get(message.user_id);
|
now,
|
||||||
if (transport && preference?.email_frequency === 'immediate') {
|
);
|
||||||
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(message.user_id);
|
await (
|
||||||
if (typeof identity?.email === 'string') {
|
await database.prepare(`UPDATE automated_system_message_deliveries
|
||||||
try {
|
SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`)
|
||||||
await transport.sendMail({
|
).run(now, notificationId, message.delivery_id);
|
||||||
from: process.env.SMTP_FROM,
|
const preference = await (
|
||||||
to: identity.email,
|
await database.prepare(
|
||||||
subject: `SHD.build · ${message.title}`,
|
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'",
|
||||||
text: `${message.body}\n\n${message.link ? `${origin}${message.link}` : origin}`,
|
)
|
||||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">${escapeHtml(message.source)}</p><h1>${escapeHtml(message.title)}</h1><p style="color:#a5aaab">${escapeHtml(message.body)}</p>${message.link ? `<a style="color:#f6a821" href="${escapeHtml(`${origin}${message.link}`)}">OPEN SHD.BUILD</a>` : ''}</div>`
|
).get(message.user_id);
|
||||||
});
|
if (transport && preference?.email_frequency === 'immediate') {
|
||||||
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId);
|
const identity = await (
|
||||||
} catch (error) {
|
await database.prepare('SELECT email FROM user_emails WHERE user_id=?')
|
||||||
console.error(`Unable to email automated notification ${notificationId}`, error);
|
).get(message.user_id);
|
||||||
}
|
if (typeof identity?.email === 'string') {
|
||||||
}
|
try {
|
||||||
}
|
await transport.sendMail({
|
||||||
delivered += 1;
|
from: process.env.SMTP_FROM,
|
||||||
|
to: identity.email,
|
||||||
|
subject: `SHD.build · ${message.title}`,
|
||||||
|
text: `${message.body}\n\n${message.link ? `${origin}${message.link}` : origin}`,
|
||||||
|
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">${escapeHtml(message.source)}</p><h1>${escapeHtml(message.title)}</h1><p style="color:#a5aaab">${escapeHtml(message.body)}</p>${message.link ? `<a style="color:#f6a821" href="${escapeHtml(`${origin}${message.link}`)}">OPEN SHD.BUILD</a>` : ''}</div>`,
|
||||||
|
});
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'UPDATE notifications SET emailed_at=? WHERE id=?',
|
||||||
|
)
|
||||||
|
).run(now, notificationId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Unable to email automated notification ${notificationId}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delivered += 1;
|
||||||
}
|
}
|
||||||
console.log(`Delivered ${delivered} automated ISAC system notification${delivered === 1 ? '' : 's'}.`);
|
console.log(
|
||||||
|
`Delivered ${delivered} automated ISAC system notification${delivered === 1 ? '' : 's'}.`,
|
||||||
|
);
|
||||||
|
|||||||
@@ -4,37 +4,41 @@ import { join, resolve } from 'node:path';
|
|||||||
|
|
||||||
const iconDirectory = resolve('./static');
|
const iconDirectory = resolve('./static');
|
||||||
const iconFiles = (await readdir(iconDirectory))
|
const iconFiles = (await readdir(iconDirectory))
|
||||||
.filter((file) => file.toLocaleLowerCase().endsWith('.png'))
|
.filter((file) => file.toLocaleLowerCase().endsWith('.png'))
|
||||||
.sort((left, right) => left.localeCompare(right));
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
|
||||||
for (const file of iconFiles) {
|
for (const file of iconFiles) {
|
||||||
const source = join(iconDirectory, file);
|
const source = join(iconDirectory, file);
|
||||||
const temporary = join(iconDirectory, `.${file}.trimmed`);
|
const temporary = join(iconDirectory, `.${file}.trimmed`);
|
||||||
const before = await sharp(source).metadata();
|
const before = await sharp(source).metadata();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sharp(source)
|
const result = await sharp(source)
|
||||||
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
.extend({
|
.extend({
|
||||||
top: 2,
|
top: 2,
|
||||||
right: 2,
|
right: 2,
|
||||||
bottom: 2,
|
bottom: 2,
|
||||||
left: 2,
|
left: 2,
|
||||||
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||||
})
|
})
|
||||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||||
.toFile(temporary);
|
.toFile(temporary);
|
||||||
|
|
||||||
if (!result.width || !result.height || result.channels !== 4) {
|
if (!result.width || !result.height || result.channels !== 4) {
|
||||||
throw new Error(`${file} did not produce a valid transparent RGBA icon.`);
|
throw new Error(`${file} did not produce a valid transparent RGBA icon.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await rename(temporary, source);
|
await rename(temporary, source);
|
||||||
console.log(`${file}: ${before.width}x${before.height} -> ${result.width}x${result.height}`);
|
console.log(
|
||||||
} catch (error) {
|
`${file}: ${before.width}x${before.height} -> ${result.width}x${result.height}`,
|
||||||
await unlink(temporary).catch(() => {});
|
);
|
||||||
throw error;
|
} catch (error) {
|
||||||
}
|
await unlink(temporary).catch(() => {});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Trimmed ${iconFiles.length} static PNG icons with a 2px transparent safety margin.`);
|
console.log(
|
||||||
|
`Trimmed ${iconFiles.length} static PNG icons with a 2px transparent safety margin.`,
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import process from 'node:process';
|
||||||
|
import { validateRuntimeEnvironment } from './runtime-environment.js';
|
||||||
|
|
||||||
|
const mode = process.argv[2];
|
||||||
|
if (mode !== 'app' && mode !== 'scheduler') {
|
||||||
|
console.error(
|
||||||
|
'Usage: node scripts/validate-runtime-environment.mjs <app|scheduler>',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = validateRuntimeEnvironment(mode, process.env);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error(
|
||||||
|
`Invalid ${mode} runtime environment:\n${errors.map((error) => `- ${error}`).join('\n')}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
+19368
-210
File diff suppressed because one or more lines are too long
Vendored
+10
-10
@@ -1,16 +1,16 @@
|
|||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
interface Error {
|
interface Error {
|
||||||
message: string;
|
message: string;
|
||||||
errorId?: string;
|
errorId?: string;
|
||||||
}
|
}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
+9
-9
@@ -1,12 +1,12 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="text-scale" content="scale" />
|
<meta name="text-scale" content="scale" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+137
-9
@@ -1,12 +1,140 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>%sveltekit.status% · SHD.build system fault</title>
|
<title>%sveltekit.status% · SHD.build system fault</title>
|
||||||
<style>
|
<style>
|
||||||
:root{color-scheme:dark;font-family:Arial,Helvetica,sans-serif;background:#0a0c0d;color:#ecebe5}*{box-sizing:border-box}body{min-height:100vh;margin:0;display:grid;place-items:center;padding:24px;background:linear-gradient(135deg,#101314 0,#0a0c0d 55%)}body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.08;background-image:linear-gradient(#ffffff14 1px,transparent 1px),linear-gradient(90deg,#ffffff14 1px,transparent 1px);background-size:48px 48px}main{position:relative;width:min(760px,100%);border:1px solid #3b4142;border-top:2px solid #df8069;background:#111415}header{height:64px;display:flex;align-items:center;justify-content:space-between;padding:0 20px;border-bottom:1px solid #303536}header div{display:grid;gap:4px}header span{color:#858b8d;font-size:8px;font-weight:900;letter-spacing:1.5px}header strong{color:#df8069;font-size:11px;letter-spacing:1px}header b{font-size:32px;color:#df8069}section{padding:clamp(28px,6vw,58px)}p{color:#858b8d;font:15px/1.6 Georgia,serif}small{display:block;color:#f6a821;font-size:8px;font-weight:900;letter-spacing:1.8px}h1{max-width:620px;margin:14px 0;font-size:clamp(34px,7vw,62px);line-height:.92;letter-spacing:-2px}a{display:inline-block;margin-top:20px;padding:14px 18px;background:#f6a821;color:#111;font-size:9px;font-weight:900;letter-spacing:1.2px;text-decoration:none}footer{display:flex;align-items:center;gap:8px;padding:12px 20px;border-top:1px solid #303536;color:#697174;font-size:7px;letter-spacing:1px}footer i{width:6px;height:6px;background:#df8069;box-shadow:0 0 7px #df8069}
|
:root {
|
||||||
</style>
|
color-scheme: dark;
|
||||||
</head>
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
<body><main><header><div><span>ISAC FALLBACK CHANNEL</span><strong>CORE SHELL INTERRUPTED</strong></div><b>%sveltekit.status%</b></header><section><small>SHD NETWORK RESPONSE</small><h1>SYSTEM INTERFACE UNAVAILABLE</h1><p>%sveltekit.error.message%</p><a href="/">RETURN TO SHD.BUILD →</a></section><footer><i></i> FAULT ISOLATED · CORE SERVICES PROTECTED</footer></main></body>
|
background: #0a0c0d;
|
||||||
|
color: #ecebe5;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: linear-gradient(135deg, #101314 0, #0a0c0d 55%);
|
||||||
|
}
|
||||||
|
body:before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.08;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(#ffffff14 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, #ffffff14 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
position: relative;
|
||||||
|
width: min(760px, 100%);
|
||||||
|
border: 1px solid #3b4142;
|
||||||
|
border-top: 2px solid #df8069;
|
||||||
|
background: #111415;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-bottom: 1px solid #303536;
|
||||||
|
}
|
||||||
|
header div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
header span {
|
||||||
|
color: #858b8d;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
}
|
||||||
|
header strong {
|
||||||
|
color: #df8069;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
header b {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #df8069;
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
padding: clamp(28px, 6vw, 58px);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #858b8d;
|
||||||
|
font:
|
||||||
|
15px/1.6 Georgia,
|
||||||
|
serif;
|
||||||
|
}
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
color: #f6a821;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 1.8px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
max-width: 620px;
|
||||||
|
margin: 14px 0;
|
||||||
|
font-size: clamp(34px, 7vw, 62px);
|
||||||
|
line-height: 0.92;
|
||||||
|
letter-spacing: -2px;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: #f6a821;
|
||||||
|
color: #111;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid #303536;
|
||||||
|
color: #697174;
|
||||||
|
font-size: 7px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
footer i {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: #df8069;
|
||||||
|
box-shadow: 0 0 7px #df8069;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span>ISAC FALLBACK CHANNEL</span
|
||||||
|
><strong>CORE SHELL INTERRUPTED</strong>
|
||||||
|
</div>
|
||||||
|
<b>%sveltekit.status%</b>
|
||||||
|
</header>
|
||||||
|
<section>
|
||||||
|
<small>SHD NETWORK RESPONSE</small>
|
||||||
|
<h1>SYSTEM INTERFACE UNAVAILABLE</h1>
|
||||||
|
<p>%sveltekit.error.message%</p>
|
||||||
|
<a href="/">RETURN TO SHD.BUILD →</a>
|
||||||
|
</section>
|
||||||
|
<footer><i></i> FAULT ISOLATED · CORE SERVICES PROTECTED</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+143
-59
@@ -2,67 +2,151 @@ import { json, type Handle, type HandleServerError } from '@sveltejs/kit';
|
|||||||
import { stringify } from 'devalue';
|
import { stringify } from 'devalue';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { DATA_MODULES, proposalActionResult, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation';
|
import {
|
||||||
|
getSiteAccessBlock,
|
||||||
|
getSiteAccessSettings,
|
||||||
|
} from '$lib/server/site-access';
|
||||||
|
import {
|
||||||
|
DATA_MODULES,
|
||||||
|
proposalActionResult,
|
||||||
|
queueDataProposal,
|
||||||
|
shouldModerateDataMutation,
|
||||||
|
type DataModule,
|
||||||
|
} from '$lib/server/data-moderation';
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
if (event.url.pathname === '/admin' || event.url.pathname.startsWith('/admin/')) {
|
if (
|
||||||
const location = `/isac${event.url.pathname.slice('/admin'.length)}${event.url.search}`;
|
event.url.pathname === '/admin' ||
|
||||||
return new Response(null, { status: 308, headers: { location } });
|
event.url.pathname.startsWith('/admin/')
|
||||||
}
|
) {
|
||||||
if (event.request.method === 'POST') {
|
const location = `/isac${event.url.pathname.slice('/admin'.length)}${event.url.search}`;
|
||||||
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
|
return new Response(null, { status: 308, headers: { location } });
|
||||||
const module = match?.[1] as DataModule | undefined;
|
}
|
||||||
const action = event.url.search.startsWith('?/') ? event.url.search.slice(2) : '';
|
const siteSettings = await getSiteAccessSettings();
|
||||||
if (module && DATA_MODULES.includes(module) && action) {
|
const sessionUser = await getSessionUser(event.cookies);
|
||||||
const user = await getSessionUser(event.cookies);
|
const accessBlock = getSiteAccessBlock(
|
||||||
const access = await shouldModerateDataMutation(user);
|
siteSettings,
|
||||||
if (!access.canManage && access.canPropose && user) {
|
event.url.pathname,
|
||||||
if (!access.contributorEnabled) {
|
Boolean(sessionUser),
|
||||||
if (event.request.headers.get('accept')?.includes('application/json')) {
|
);
|
||||||
return json({ type: 'failure', status: 403, data: { contributorModeRequired: true } }, { status: 403 });
|
if (accessBlock) {
|
||||||
}
|
const target = new URL('/coming-soon', event.url);
|
||||||
return new Response('Contributor mode must be enabled before submitting data changes.', { status: 403 });
|
if (accessBlock.kind === 'section')
|
||||||
}
|
target.searchParams.set('unavailable', accessBlock.section);
|
||||||
const submittedForm = await event.request.formData();
|
if (event.request.method === 'GET' || event.request.method === 'HEAD') {
|
||||||
const proposal = await queueDataProposal(user, module, action, submittedForm);
|
return new Response(null, {
|
||||||
if (event.request.headers.get('accept')?.includes('application/json')) {
|
status: 303,
|
||||||
const result = proposalActionResult(module, action, Boolean(proposal.entityId), proposal.id);
|
headers: { location: `${target.pathname}${target.search}` },
|
||||||
if (module === 'attributes' && action === 'save') {
|
});
|
||||||
Object.assign(result, {
|
}
|
||||||
savedItem: {
|
return json(
|
||||||
id: proposal.entityId ?? proposal.draftEntityId,
|
{
|
||||||
name: String(submittedForm.get('name') ?? ''),
|
error:
|
||||||
type: String(submittedForm.get('type') ?? ''),
|
accessBlock.kind === 'section'
|
||||||
category: String(submittedForm.get('category') ?? ''),
|
? 'This section is not available.'
|
||||||
calculation_type: String(submittedForm.get('calculationType') ?? ''),
|
: 'The public site is not available yet.',
|
||||||
unit: String(submittedForm.get('unit') ?? ''),
|
},
|
||||||
active: 1,
|
{ status: 404 },
|
||||||
pending: true,
|
);
|
||||||
proposalId: proposal.id,
|
}
|
||||||
proposerName: user.displayName
|
if (event.request.method === 'POST') {
|
||||||
}
|
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
|
||||||
});
|
const module = match?.[1] as DataModule | undefined;
|
||||||
}
|
const action = event.url.search.startsWith('?/')
|
||||||
return json({
|
? event.url.search.slice(2)
|
||||||
type: 'success',
|
: '';
|
||||||
status: 200,
|
if (module && DATA_MODULES.includes(module) && action) {
|
||||||
data: stringify(result)
|
// Publishing calculation rules is never a contributor proposal. The
|
||||||
});
|
// action has its own dedicated permission and confirmation contract.
|
||||||
}
|
if (module === 'calculations' && action === 'publishRevision')
|
||||||
const target = new URL(event.url.pathname, event.url);
|
return resolve(event);
|
||||||
target.searchParams.set('proposal', proposal.id);
|
const user = sessionUser;
|
||||||
return new Response(null, { status: 303, headers: { location: `${target.pathname}${target.search}` } });
|
const access = await shouldModerateDataMutation(user);
|
||||||
}
|
if (!access.canManage && access.canPropose && user) {
|
||||||
}
|
if (!access.contributorEnabled) {
|
||||||
}
|
if (
|
||||||
return resolve(event);
|
event.request.headers.get('accept')?.includes('application/json')
|
||||||
|
) {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
type: 'failure',
|
||||||
|
status: 403,
|
||||||
|
data: { contributorModeRequired: true },
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response(
|
||||||
|
'Contributor mode must be enabled before submitting data changes.',
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const submittedForm = await event.request.formData();
|
||||||
|
const proposal = await queueDataProposal(
|
||||||
|
user,
|
||||||
|
module,
|
||||||
|
action,
|
||||||
|
submittedForm,
|
||||||
|
);
|
||||||
|
if (event.request.headers.get('accept')?.includes('application/json')) {
|
||||||
|
const result = proposalActionResult(
|
||||||
|
module,
|
||||||
|
action,
|
||||||
|
Boolean(proposal.entityId),
|
||||||
|
proposal.id,
|
||||||
|
);
|
||||||
|
if (module === 'attributes' && action === 'save') {
|
||||||
|
Object.assign(result, {
|
||||||
|
savedItem: {
|
||||||
|
id: proposal.entityId ?? proposal.draftEntityId,
|
||||||
|
name: String(submittedForm.get('name') ?? ''),
|
||||||
|
type: String(submittedForm.get('type') ?? ''),
|
||||||
|
category: String(submittedForm.get('category') ?? ''),
|
||||||
|
calculation_type: String(
|
||||||
|
submittedForm.get('calculationType') ?? '',
|
||||||
|
),
|
||||||
|
unit: String(submittedForm.get('unit') ?? ''),
|
||||||
|
active: 1,
|
||||||
|
pending: true,
|
||||||
|
proposalId: proposal.id,
|
||||||
|
proposerName: user.displayName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return json({
|
||||||
|
type: 'success',
|
||||||
|
status: 200,
|
||||||
|
data: stringify(result),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const target = new URL(event.url.pathname, event.url);
|
||||||
|
target.searchParams.set('proposal', proposal.id);
|
||||||
|
return new Response(null, {
|
||||||
|
status: 303,
|
||||||
|
headers: { location: `${target.pathname}${target.search}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolve(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleError: HandleServerError = ({ error, event, status, message }) => {
|
export const handleError: HandleServerError = ({
|
||||||
const errorId = randomUUID().slice(0, 8).toUpperCase();
|
error,
|
||||||
console.error(`[${errorId}] ${event.request.method} ${event.url.pathname} -> ${status}`, error);
|
event,
|
||||||
return {
|
status,
|
||||||
message: status >= 500 ? 'An unexpected fault interrupted this operation. No further action was applied.' : message,
|
message,
|
||||||
errorId
|
}) => {
|
||||||
};
|
const errorId = randomUUID().slice(0, 8).toUpperCase();
|
||||||
|
console.error(
|
||||||
|
`[${errorId}] ${event.request.method} ${event.url.pathname} -> ${status}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
status >= 500
|
||||||
|
? 'An unexpected fault interrupted this operation. No further action was applied.'
|
||||||
|
: message,
|
||||||
|
errorId,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
agentProfileHref,
|
||||||
|
displayNameKey,
|
||||||
|
newAgentDisplayName,
|
||||||
|
publicProfileSlugBase,
|
||||||
|
} from './agents.js';
|
||||||
|
|
||||||
|
describe('display names', () => {
|
||||||
|
it('uses one normalized key for uniqueness checks', () => {
|
||||||
|
expect(displayNameKey('Aewyn')).toBe(displayNameKey('Aewyn'));
|
||||||
|
expect(displayNameKey('E\u0301lite')).toBe(displayNameKey('Élite'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates readable short-UID placeholders', () => {
|
||||||
|
expect(newAgentDisplayName(47)).toBe('Agent 47');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('publicProfileSlugBase', () => {
|
||||||
|
it('creates readable normalized slugs', () => {
|
||||||
|
expect(
|
||||||
|
publicProfileSlugBase(
|
||||||
|
' Aewyn Prime ',
|
||||||
|
'12345678-0000-4000-8000-000000000000',
|
||||||
|
),
|
||||||
|
).toBe('aewyn-prime');
|
||||||
|
expect(
|
||||||
|
publicProfileSlugBase(
|
||||||
|
'Élite 装備',
|
||||||
|
'12345678-0000-4000-8000-000000000000',
|
||||||
|
),
|
||||||
|
).toBe('élite-装備');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a stable fallback when a name has no letters or numbers', () => {
|
||||||
|
expect(
|
||||||
|
publicProfileSlugBase('⚡️', '12345678-0000-4000-8000-000000000000'),
|
||||||
|
).toBe('agent-12345678');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps UUID-shaped display names out of the legacy UUID namespace', () => {
|
||||||
|
expect(
|
||||||
|
publicProfileSlugBase(
|
||||||
|
'57a1160e-632f-4695-927f-7df03e20753f',
|
||||||
|
'12345678-0000-4000-8000-000000000000',
|
||||||
|
),
|
||||||
|
).toBe('agent-57a1160e-632f-4695-927f-7df03e20753f');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('agentProfileHref', () => {
|
||||||
|
it('prefers the public slug and safely encodes it', () => {
|
||||||
|
expect(
|
||||||
|
agentProfileHref(
|
||||||
|
{ id: 'uuid', publicSlug: 'élite 装備' },
|
||||||
|
'profile-comments',
|
||||||
|
),
|
||||||
|
).toBe('/agents/%C3%A9lite%20%E8%A3%85%E5%82%99#profile-comments');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the UUID for unmigrated records', () => {
|
||||||
|
expect(agentProfileHref({ id: 'uuid' })).toBe('/agents/uuid');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export type AgentProfileReference = {
|
||||||
|
id: string;
|
||||||
|
publicSlug?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UUID_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export function displayNameKey(displayName: string): string {
|
||||||
|
return displayName.normalize('NFKC').toLocaleLowerCase('en-US');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newAgentDisplayName(sequence: number): string {
|
||||||
|
return `Agent ${Math.max(1, Math.trunc(sequence))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicProfileSlugBase(
|
||||||
|
displayName: string,
|
||||||
|
userId: string,
|
||||||
|
): string {
|
||||||
|
const normalized = displayName
|
||||||
|
.normalize('NFKC')
|
||||||
|
.toLocaleLowerCase('en-US')
|
||||||
|
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 64)
|
||||||
|
.replace(/-+$/g, '');
|
||||||
|
const fallback = `agent-${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`;
|
||||||
|
if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback;
|
||||||
|
return UUID_PATTERN.test(normalized) ? `agent-${normalized}` : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentProfileHref(
|
||||||
|
agent: AgentProfileReference,
|
||||||
|
fragment = '',
|
||||||
|
): string {
|
||||||
|
const identifier = agent.publicSlug || agent.id;
|
||||||
|
const suffix = fragment ? `#${fragment.replace(/^#/, '')}` : '';
|
||||||
|
return `/agents/${encodeURIComponent(identifier)}${suffix}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type {
|
||||||
|
AiMechanicQuestion,
|
||||||
|
AiMechanicResponseMeta,
|
||||||
|
} from './mechanic-authoring';
|
||||||
|
|
||||||
|
export const AI_CALCULATION_STAGE_OPERATORS = [
|
||||||
|
'base_damage',
|
||||||
|
'flat_add',
|
||||||
|
'additive_percent',
|
||||||
|
'multiplicative_percent',
|
||||||
|
'expected_critical',
|
||||||
|
'expected_headshot',
|
||||||
|
'rate_of_fire',
|
||||||
|
'magazine_size',
|
||||||
|
'reload_speed',
|
||||||
|
'firing_cycle',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const AI_SKILL_CALCULATION_STAGES = [
|
||||||
|
'engineering',
|
||||||
|
'flat_damage',
|
||||||
|
'skill_damage',
|
||||||
|
'final_damage',
|
||||||
|
'critical_chance',
|
||||||
|
'critical_damage',
|
||||||
|
'multishot_chance',
|
||||||
|
'multishot_damage',
|
||||||
|
'cooldown_recovery',
|
||||||
|
'duration',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AiCalculationStageDraft = {
|
||||||
|
stageKey: string;
|
||||||
|
label: string;
|
||||||
|
operator: (typeof AI_CALCULATION_STAGE_OPERATORS)[number];
|
||||||
|
displayOrder: number;
|
||||||
|
lockedPosition: 'first' | 'last' | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiCalculationProfileDraft = {
|
||||||
|
statAttributeId: string;
|
||||||
|
stageKey: string;
|
||||||
|
weight: number;
|
||||||
|
softCap: number | null;
|
||||||
|
hardCap: number | null;
|
||||||
|
curveType: 'none' | 'power' | 'smoothstep';
|
||||||
|
curveParameter: number | null;
|
||||||
|
weaponCategory: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiSkillCalculationProfileDraft = {
|
||||||
|
statAttributeId: string;
|
||||||
|
stageKey: (typeof AI_SKILL_CALCULATION_STAGES)[number];
|
||||||
|
operator: 'add' | 'multiply';
|
||||||
|
weight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiCalculationRevisionDraft = {
|
||||||
|
name: string;
|
||||||
|
notes: string;
|
||||||
|
stages: AiCalculationStageDraft[];
|
||||||
|
profiles: AiCalculationProfileDraft[];
|
||||||
|
skillProfiles: AiSkillCalculationProfileDraft[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiCalculationTranscriptEntry = {
|
||||||
|
questionId: string;
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiCalculationAuthoringRequest = {
|
||||||
|
instructions: string;
|
||||||
|
baseRevisionId: string | null;
|
||||||
|
currentDraft: AiCalculationRevisionDraft;
|
||||||
|
transcript: AiCalculationTranscriptEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiCalculationQuestion = AiMechanicQuestion;
|
||||||
|
|
||||||
|
export type AiCalculationCapabilityGap = {
|
||||||
|
area: 'stage_operator' | 'curve' | 'skill_stage' | 'attribute' | 'runtime';
|
||||||
|
requiredBehavior: string;
|
||||||
|
whyUnsupported: string;
|
||||||
|
developerChange: string;
|
||||||
|
workaround: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiCalculationClarificationResponse = {
|
||||||
|
status: 'clarification_required';
|
||||||
|
summary: string;
|
||||||
|
assumptions: string[];
|
||||||
|
warnings: string[];
|
||||||
|
questions: AiCalculationQuestion[];
|
||||||
|
meta: AiMechanicResponseMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiCalculationDraftResponse = {
|
||||||
|
status: 'draft_ready';
|
||||||
|
draft: AiCalculationRevisionDraft;
|
||||||
|
summary: string;
|
||||||
|
assumptions: string[];
|
||||||
|
warnings: string[];
|
||||||
|
pseudoScript: string;
|
||||||
|
meta: AiMechanicResponseMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiCalculationCapabilityGapResponse = {
|
||||||
|
status: 'capability_gap';
|
||||||
|
summary: string;
|
||||||
|
assumptions: string[];
|
||||||
|
warnings: string[];
|
||||||
|
gaps: AiCalculationCapabilityGap[];
|
||||||
|
meta: AiMechanicResponseMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiCalculationAuthoringResponse =
|
||||||
|
| AiCalculationClarificationResponse
|
||||||
|
| AiCalculationDraftResponse
|
||||||
|
| AiCalculationCapabilityGapResponse;
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||||||
|
|
||||||
|
export const AI_MECHANIC_SOURCE_KINDS = [
|
||||||
|
'talent',
|
||||||
|
'exotic_weapon_talent',
|
||||||
|
'os_protocol_talent',
|
||||||
|
'skill_chip_talent',
|
||||||
|
'specialization_tree_rank',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AiMechanicSourceKind = (typeof AI_MECHANIC_SOURCE_KINDS)[number];
|
||||||
|
|
||||||
|
export const AI_MECHANIC_SOURCE_PROFILES = {
|
||||||
|
talent: {
|
||||||
|
variantKeys: ['I', 'II'],
|
||||||
|
allowAssignedAbility: false,
|
||||||
|
allowProtocolUpgradeScaling: false,
|
||||||
|
},
|
||||||
|
exotic_weapon_talent: {
|
||||||
|
variantKeys: ['I', 'II'],
|
||||||
|
allowAssignedAbility: false,
|
||||||
|
allowProtocolUpgradeScaling: false,
|
||||||
|
},
|
||||||
|
os_protocol_talent: {
|
||||||
|
variantKeys: ['base'],
|
||||||
|
allowAssignedAbility: false,
|
||||||
|
allowProtocolUpgradeScaling: true,
|
||||||
|
},
|
||||||
|
skill_chip_talent: {
|
||||||
|
variantKeys: ['minimum', 'maximum'],
|
||||||
|
allowAssignedAbility: true,
|
||||||
|
allowProtocolUpgradeScaling: false,
|
||||||
|
},
|
||||||
|
specialization_tree_rank: {
|
||||||
|
variantKeys: ['base'],
|
||||||
|
allowAssignedAbility: false,
|
||||||
|
allowProtocolUpgradeScaling: false,
|
||||||
|
},
|
||||||
|
} as const satisfies Record<
|
||||||
|
AiMechanicSourceKind,
|
||||||
|
{
|
||||||
|
variantKeys: readonly string[];
|
||||||
|
allowAssignedAbility: boolean;
|
||||||
|
allowProtocolUpgradeScaling: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type AiMechanicContext = {
|
||||||
|
sourceKind: AiMechanicSourceKind;
|
||||||
|
sourceName: string;
|
||||||
|
sourceDescription: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiMechanicValueColumn = {
|
||||||
|
key: string;
|
||||||
|
variantKey: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiMechanicTranscriptEntry = {
|
||||||
|
questionId: string;
|
||||||
|
question: string;
|
||||||
|
answer: string | number | boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiMechanicAuthoringRequest = {
|
||||||
|
context: AiMechanicContext;
|
||||||
|
currentDraft: ConditionalMechanicDraftV1;
|
||||||
|
valueColumns: AiMechanicValueColumn[];
|
||||||
|
allowAssignedAbility: boolean;
|
||||||
|
allowProtocolUpgradeScaling: boolean;
|
||||||
|
transcript: AiMechanicTranscriptEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiMechanicQuestion = {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
reason: string;
|
||||||
|
answerType: 'text' | 'number' | 'choice' | 'boolean';
|
||||||
|
options: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiMechanicResponseMeta = {
|
||||||
|
requestId: string;
|
||||||
|
model: string;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiMechanicClarificationResponse = {
|
||||||
|
status: 'clarification_required';
|
||||||
|
summary: string;
|
||||||
|
assumptions: string[];
|
||||||
|
warnings: string[];
|
||||||
|
questions: AiMechanicQuestion[];
|
||||||
|
meta: AiMechanicResponseMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiMechanicDraftResponse = {
|
||||||
|
status: 'draft_ready';
|
||||||
|
draft: ConditionalMechanicDraftV1;
|
||||||
|
summary: string;
|
||||||
|
assumptions: string[];
|
||||||
|
warnings: string[];
|
||||||
|
pseudoScript: string;
|
||||||
|
meta: AiMechanicResponseMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiMechanicCapabilityGap = {
|
||||||
|
area:
|
||||||
|
| 'event'
|
||||||
|
| 'predicate'
|
||||||
|
| 'reducer'
|
||||||
|
| 'mutation'
|
||||||
|
| 'outcome'
|
||||||
|
| 'scope'
|
||||||
|
| 'value_reference'
|
||||||
|
| 'source_variant'
|
||||||
|
| 'runtime';
|
||||||
|
requiredBehavior: string;
|
||||||
|
whyUnsupported: string;
|
||||||
|
developerChange: string;
|
||||||
|
workaround: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiMechanicCapabilityGapResponse = {
|
||||||
|
status: 'capability_gap';
|
||||||
|
summary: string;
|
||||||
|
assumptions: string[];
|
||||||
|
warnings: string[];
|
||||||
|
gaps: AiMechanicCapabilityGap[];
|
||||||
|
meta: AiMechanicResponseMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiMechanicAuthoringResponse =
|
||||||
|
| AiMechanicClarificationResponse
|
||||||
|
| AiMechanicDraftResponse
|
||||||
|
| AiMechanicCapabilityGapResponse;
|
||||||
|
|
||||||
|
export type AiMechanicAuthoringError = {
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildMechanicDeveloperPrompt,
|
||||||
|
type AiMechanicDeveloperHandoff,
|
||||||
|
} from './mechanic-developer-handoff';
|
||||||
|
|
||||||
|
const handoff: AiMechanicDeveloperHandoff = {
|
||||||
|
context: {
|
||||||
|
sourceKind: 'talent',
|
||||||
|
sourceName: 'Chain Reaction',
|
||||||
|
sourceDescription:
|
||||||
|
'Every third critical hit transfers the mark to a nearby enemy.',
|
||||||
|
},
|
||||||
|
valueColumns: [
|
||||||
|
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
|
||||||
|
{ key: 'tierII', variantKey: 'II', label: 'TIER II' },
|
||||||
|
],
|
||||||
|
allowAssignedAbility: false,
|
||||||
|
allowProtocolUpgradeScaling: false,
|
||||||
|
transcript: [
|
||||||
|
{
|
||||||
|
questionId: 'transfer-target',
|
||||||
|
question: 'How is the next target selected?',
|
||||||
|
answer: 'Nearest enemy',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response: {
|
||||||
|
status: 'capability_gap',
|
||||||
|
summary: 'The runtime cannot select a nearby transfer target.',
|
||||||
|
assumptions: ['The third hit consumes the current mark.'],
|
||||||
|
warnings: ['Target selection must be deterministic.'],
|
||||||
|
gaps: [
|
||||||
|
{
|
||||||
|
area: 'runtime',
|
||||||
|
requiredBehavior: 'Select the nearest eligible enemy.',
|
||||||
|
whyUnsupported: 'The event has no ordered nearby-target collection.',
|
||||||
|
developerChange: 'Add a deterministic nearby-target selector.',
|
||||||
|
workaround: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
requestId: 'req_gap_123',
|
||||||
|
model: 'test-model',
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 50,
|
||||||
|
totalTokens: 150,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('mechanic developer handoff prompt', () => {
|
||||||
|
it('includes the exact analyzed source, clarification evidence, and required code change', () => {
|
||||||
|
const prompt = buildMechanicDeveloperPrompt(handoff);
|
||||||
|
|
||||||
|
expect(prompt).toContain(handoff.context.sourceDescription);
|
||||||
|
expect(prompt).toContain('How is the next target selected?');
|
||||||
|
expect(prompt).toContain('Answer: Nearest enemy');
|
||||||
|
expect(prompt).toContain('Add a deterministic nearby-target selector.');
|
||||||
|
expect(prompt).toContain('Assistant request ID: req_gap_123');
|
||||||
|
expect(prompt).toContain('Persistence round-trips the new representation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits empty optional sections', () => {
|
||||||
|
const prompt = buildMechanicDeveloperPrompt({
|
||||||
|
...handoff,
|
||||||
|
transcript: [],
|
||||||
|
response: { ...handoff.response, assumptions: [], warnings: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompt).not.toContain('## Clarification history');
|
||||||
|
expect(prompt).not.toContain('## Assumptions');
|
||||||
|
expect(prompt).not.toContain('## Warnings');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type {
|
||||||
|
AiMechanicCapabilityGapResponse,
|
||||||
|
AiMechanicContext,
|
||||||
|
AiMechanicTranscriptEntry,
|
||||||
|
AiMechanicValueColumn,
|
||||||
|
} from '$lib/ai/mechanic-authoring';
|
||||||
|
|
||||||
|
export type AiMechanicDeveloperHandoff = {
|
||||||
|
context: AiMechanicContext;
|
||||||
|
valueColumns: AiMechanicValueColumn[];
|
||||||
|
allowAssignedAbility: boolean;
|
||||||
|
allowProtocolUpgradeScaling: boolean;
|
||||||
|
transcript: AiMechanicTranscriptEntry[];
|
||||||
|
response: AiMechanicCapabilityGapResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
function section(title: string, items: string[]) {
|
||||||
|
return items.length ? `\n## ${title}\n\n${items.join('\n')}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMechanicDeveloperPrompt(
|
||||||
|
handoff: AiMechanicDeveloperHandoff,
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
context,
|
||||||
|
valueColumns,
|
||||||
|
allowAssignedAbility,
|
||||||
|
allowProtocolUpgradeScaling,
|
||||||
|
transcript,
|
||||||
|
response,
|
||||||
|
} = handoff;
|
||||||
|
const variants = valueColumns
|
||||||
|
.map((column) => `${column.label} (${column.variantKey})`)
|
||||||
|
.join(', ');
|
||||||
|
const clarifications = transcript.map(
|
||||||
|
(entry) => `- ${entry.question}\n Answer: ${String(entry.answer)}`,
|
||||||
|
);
|
||||||
|
const assumptions = response.assumptions.map((value) => `- ${value}`);
|
||||||
|
const warnings = response.warnings.map((value) => `- ${value}`);
|
||||||
|
const gaps = response.gaps.map((gap, index) =>
|
||||||
|
[
|
||||||
|
`### Gap ${index + 1}: ${gap.area}`,
|
||||||
|
`- Required behavior: ${gap.requiredBehavior}`,
|
||||||
|
`- Why unsupported: ${gap.whyUnsupported}`,
|
||||||
|
`- Developer change: ${gap.developerChange}`,
|
||||||
|
gap.workaround
|
||||||
|
? `- Current workaround: ${gap.workaround}`
|
||||||
|
: '- Current workaround: None',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return `Implement support for the following ISAC mechanic-language gap in the SHD.build repository.
|
||||||
|
|
||||||
|
Start by tracing the existing typed mechanic grammar, persistence adapter, runtime, editor, AI authoring context, and tests. Extend the smallest reusable layer that can represent the behavior exactly. Do not invent combat behavior or weaken deterministic validation. Preserve manual mechanic authoring and existing saved game data. Update shared registries, types, validation, persistence, runtime evaluation, editor controls, AI prompt context, tests, and maintained architecture/design documentation wherever the implementation requires them. Follow the repository's audit, database-preservation, accessibility, and development-server rules.
|
||||||
|
|
||||||
|
## Source mechanic
|
||||||
|
|
||||||
|
- Source type: ${context.sourceKind}
|
||||||
|
- Source name: ${context.sourceName || 'Untitled mechanic'}
|
||||||
|
- Supported variants: ${variants || 'None supplied'}
|
||||||
|
- Assigned-ability scope allowed: ${allowAssignedAbility ? 'Yes' : 'No'}
|
||||||
|
- OS Protocol upgrade scaling allowed: ${allowProtocolUpgradeScaling ? 'Yes' : 'No'}
|
||||||
|
- Assistant request ID: ${response.meta.requestId}
|
||||||
|
|
||||||
|
Exact analyzed wording:
|
||||||
|
|
||||||
|
${context.sourceDescription}
|
||||||
|
|
||||||
|
## Assistant diagnosis
|
||||||
|
|
||||||
|
${response.summary}${section('Clarification history', clarifications)}${section('Assumptions', assumptions)}${section('Warnings', warnings)}
|
||||||
|
|
||||||
|
## Required language changes
|
||||||
|
|
||||||
|
${gaps.join('\n\n')}
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- The mechanic can be authored without a workaround and passes deterministic validation.
|
||||||
|
- Persistence round-trips the new representation without losing meaning.
|
||||||
|
- The calculation runtime produces verified results for the new behavior.
|
||||||
|
- Existing mechanics continue to parse and calculate unchanged.
|
||||||
|
- Focused tests cover valid behavior, invalid input, boundary cases, and any new editor state.
|
||||||
|
- The AI assistant context advertises the capability only after the application supports it.`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { EMPTY_ARTICLE_DOCUMENT, parseArticleDocument } from './document';
|
||||||
|
|
||||||
|
describe('article documents', () => {
|
||||||
|
it('accepts formatted prose, multiple Builds, and article images', () => {
|
||||||
|
const parsed = parseArticleDocument({
|
||||||
|
version: 1,
|
||||||
|
document: {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'heading',
|
||||||
|
attrs: { level: 2 },
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Opening', marks: [{ type: 'bold' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Use this rotation.',
|
||||||
|
marks: [
|
||||||
|
{ type: 'link', attrs: { href: 'https://shd.build/guides' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'buildEmbed',
|
||||||
|
attrs: {
|
||||||
|
buildId: '11111111-1111-4111-8111-111111111111',
|
||||||
|
embedKey: 'build-11111111-1111-4111-8111-111111111111',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'buildEmbed',
|
||||||
|
attrs: {
|
||||||
|
buildId: '22222222-2222-4222-8222-222222222222',
|
||||||
|
embedKey: 'build-22222222-2222-4222-8222-222222222222',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'articleImage',
|
||||||
|
attrs: {
|
||||||
|
imageId: '33333333-3333-4333-8333-333333333333',
|
||||||
|
alt: 'A loadout screen',
|
||||||
|
caption: 'Final configuration',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(parsed?.buildEmbeds).toHaveLength(2);
|
||||||
|
expect(parsed?.imageIds).toEqual(['33333333-3333-4333-8333-333333333333']);
|
||||||
|
expect(parsed?.plainText).toContain('Opening Use this rotation.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsafe links and unknown nodes', () => {
|
||||||
|
expect(
|
||||||
|
parseArticleDocument({
|
||||||
|
version: 1,
|
||||||
|
document: {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'unsafe',
|
||||||
|
marks: [
|
||||||
|
{ type: 'link', attrs: { href: 'javascript:alert(1)' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseArticleDocument({
|
||||||
|
version: 1,
|
||||||
|
document: {
|
||||||
|
type: 'doc',
|
||||||
|
content: [{ type: 'iframe', attrs: { src: 'https://example.com' } }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts and canonicalizes metadata emitted by the Tiptap editor', () => {
|
||||||
|
const parsed = parseArticleDocument({
|
||||||
|
version: 1,
|
||||||
|
document: {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Read more',
|
||||||
|
marks: [
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
attrs: {
|
||||||
|
href: 'https://shd.build/news',
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer nofollow',
|
||||||
|
class: null,
|
||||||
|
title: 'News archive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'orderedList',
|
||||||
|
attrs: { start: 1, type: null },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'listItem',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [{ type: 'text', text: 'First item' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed?.document.document.content).toEqual([
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Read more',
|
||||||
|
marks: [
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
attrs: {
|
||||||
|
href: 'https://shd.build/news',
|
||||||
|
title: 'News archive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'orderedList',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'listItem',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [{ type: 'text', text: 'First item' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(parsed?.plainText).toContain('News archive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves supported ordered-list numbering', () => {
|
||||||
|
const parsed = parseArticleDocument({
|
||||||
|
version: 1,
|
||||||
|
document: {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'orderedList',
|
||||||
|
attrs: { start: 2, type: 'A' },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'listItem',
|
||||||
|
content: [{ type: 'paragraph' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed?.document.document.content?.[0]).toMatchObject({
|
||||||
|
type: 'orderedList',
|
||||||
|
attrs: { start: 2, type: 'A' },
|
||||||
|
});
|
||||||
|
expect(parseArticleDocument(parsed?.document)?.document).toEqual(
|
||||||
|
parsed?.document,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown link metadata and non-default ordered lists', () => {
|
||||||
|
const documentWith = (content: unknown[]) => ({
|
||||||
|
version: 1,
|
||||||
|
document: { type: 'doc', content },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
parseArticleDocument(
|
||||||
|
documentWith([
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'unsafe metadata',
|
||||||
|
marks: [
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
attrs: {
|
||||||
|
href: 'https://shd.build',
|
||||||
|
onclick: 'alert(1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseArticleDocument(
|
||||||
|
documentWith([
|
||||||
|
{
|
||||||
|
type: 'orderedList',
|
||||||
|
attrs: { start: 0, type: 'decimal' },
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseArticleDocument(
|
||||||
|
documentWith([
|
||||||
|
{
|
||||||
|
type: 'orderedList',
|
||||||
|
attrs: { start: 1, type: 1 },
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate embed keys and unknown envelope fields', () => {
|
||||||
|
const embed = {
|
||||||
|
type: 'buildEmbed',
|
||||||
|
attrs: {
|
||||||
|
buildId: '11111111-1111-4111-8111-111111111111',
|
||||||
|
embedKey: 'build-11111111-1111-4111-8111-111111111111',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
parseArticleDocument({
|
||||||
|
version: 1,
|
||||||
|
document: { type: 'doc', content: [embed, embed] },
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseArticleDocument({ ...EMPTY_ARTICLE_DOCUMENT, legacy: true }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
export const ARTICLE_DOCUMENT_VERSION = 1 as const;
|
||||||
|
export const MAX_ARTICLE_DOCUMENT_BYTES = 240_000;
|
||||||
|
export const MAX_ARTICLE_TEXT_CHARACTERS = 60_000;
|
||||||
|
export const MAX_ARTICLE_NODES = 1_200;
|
||||||
|
export const MAX_ARTICLE_DEPTH = 8;
|
||||||
|
|
||||||
|
const ID_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
const EMBED_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{7,63}$/;
|
||||||
|
|
||||||
|
export type ArticleMark = {
|
||||||
|
type: 'bold' | 'italic' | 'code' | 'link';
|
||||||
|
attrs?: { href: string; title?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArticleNode = {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, unknown>;
|
||||||
|
content?: ArticleNode[];
|
||||||
|
marks?: ArticleMark[];
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArticleDocument = {
|
||||||
|
version: typeof ARTICLE_DOCUMENT_VERSION;
|
||||||
|
document: ArticleNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArticleBuildEmbed = {
|
||||||
|
embedKey: string;
|
||||||
|
buildId: string;
|
||||||
|
ordinal: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EMPTY_ARTICLE_DOCUMENT: ArticleDocument = {
|
||||||
|
version: ARTICLE_DOCUMENT_VERSION,
|
||||||
|
document: { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
const exactKeys = (value: Record<string, unknown>, allowed: string[]) =>
|
||||||
|
Object.keys(value).every((key) => allowed.includes(key));
|
||||||
|
|
||||||
|
const safeLink = (value: unknown) => {
|
||||||
|
if (typeof value !== 'string' || value.length > 2_000) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return ['http:', 'https:'].includes(url.protocol) ? url.toString() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nullableBoundedString = (value: unknown, maximum: number) =>
|
||||||
|
value === undefined ||
|
||||||
|
value === null ||
|
||||||
|
(typeof value === 'string' &&
|
||||||
|
value.length <= maximum &&
|
||||||
|
!/\p{Cc}/u.test(value));
|
||||||
|
|
||||||
|
function parseMarks(
|
||||||
|
value: unknown,
|
||||||
|
onLinkTitle: (title: string) => void,
|
||||||
|
): ArticleMark[] | null {
|
||||||
|
if (value === undefined) return [];
|
||||||
|
if (!Array.isArray(value) || value.length > 4) return null;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const marks: ArticleMark[] = [];
|
||||||
|
for (const candidate of value) {
|
||||||
|
if (
|
||||||
|
!isRecord(candidate) ||
|
||||||
|
typeof candidate.type !== 'string' ||
|
||||||
|
seen.has(candidate.type)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
seen.add(candidate.type);
|
||||||
|
if (['bold', 'italic', 'code'].includes(candidate.type)) {
|
||||||
|
if (!exactKeys(candidate, ['type'])) return null;
|
||||||
|
marks.push({ type: candidate.type as 'bold' | 'italic' | 'code' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
candidate.type !== 'link' ||
|
||||||
|
!exactKeys(candidate, ['type', 'attrs']) ||
|
||||||
|
!isRecord(candidate.attrs)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
!exactKeys(candidate.attrs, [
|
||||||
|
'href',
|
||||||
|
'target',
|
||||||
|
'rel',
|
||||||
|
'class',
|
||||||
|
'title',
|
||||||
|
]) ||
|
||||||
|
!nullableBoundedString(candidate.attrs.target, 100) ||
|
||||||
|
!nullableBoundedString(candidate.attrs.rel, 500) ||
|
||||||
|
!nullableBoundedString(candidate.attrs.class, 500) ||
|
||||||
|
!nullableBoundedString(candidate.attrs.title, 500)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const href = safeLink(candidate.attrs.href);
|
||||||
|
if (!href) return null;
|
||||||
|
const title =
|
||||||
|
typeof candidate.attrs.title === 'string'
|
||||||
|
? candidate.attrs.title.normalize('NFKC').trim()
|
||||||
|
: '';
|
||||||
|
if (title) onLinkTitle(title);
|
||||||
|
marks.push({
|
||||||
|
type: 'link',
|
||||||
|
attrs: { href, ...(title ? { title } : {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return marks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseArticleDocument(value: unknown): {
|
||||||
|
document: ArticleDocument;
|
||||||
|
plainText: string;
|
||||||
|
buildEmbeds: ArticleBuildEmbed[];
|
||||||
|
imageIds: string[];
|
||||||
|
links: string[];
|
||||||
|
} | null {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (new TextEncoder().encode(value).byteLength > MAX_ARTICLE_DOCUMENT_BYTES)
|
||||||
|
return null;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isRecord(value) ||
|
||||||
|
!exactKeys(value, ['version', 'document']) ||
|
||||||
|
value.version !== ARTICLE_DOCUMENT_VERSION
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
let nodeCount = 0;
|
||||||
|
let textLength = 0;
|
||||||
|
let embedOrdinal = 0;
|
||||||
|
const textParts: string[] = [];
|
||||||
|
const buildEmbeds: ArticleBuildEmbed[] = [];
|
||||||
|
const imageIds: string[] = [];
|
||||||
|
const embedKeys = new Set<string>();
|
||||||
|
|
||||||
|
const parseNode = (candidate: unknown, depth: number): ArticleNode | null => {
|
||||||
|
if (
|
||||||
|
!isRecord(candidate) ||
|
||||||
|
depth > MAX_ARTICLE_DEPTH ||
|
||||||
|
++nodeCount > MAX_ARTICLE_NODES
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (typeof candidate.type !== 'string') return null;
|
||||||
|
const type = candidate.type;
|
||||||
|
if (type === 'text') {
|
||||||
|
if (
|
||||||
|
!exactKeys(candidate, ['type', 'text', 'marks']) ||
|
||||||
|
typeof candidate.text !== 'string'
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const text = candidate.text.normalize('NFKC');
|
||||||
|
if (/\p{Cc}/u.test(text.replace(/[\n\t]/g, ''))) return null;
|
||||||
|
textLength += [...text].length;
|
||||||
|
if (textLength > MAX_ARTICLE_TEXT_CHARACTERS) return null;
|
||||||
|
const marks = parseMarks(candidate.marks, (title) =>
|
||||||
|
textParts.push(title),
|
||||||
|
);
|
||||||
|
if (!marks) return null;
|
||||||
|
textParts.push(text);
|
||||||
|
return marks.length ? { type, text, marks } : { type, text };
|
||||||
|
}
|
||||||
|
if (type === 'buildEmbed') {
|
||||||
|
if (
|
||||||
|
!exactKeys(candidate, ['type', 'attrs']) ||
|
||||||
|
!isRecord(candidate.attrs) ||
|
||||||
|
!exactKeys(candidate.attrs, ['buildId', 'embedKey'])
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const buildId = String(candidate.attrs.buildId ?? '');
|
||||||
|
const embedKey = String(candidate.attrs.embedKey ?? '');
|
||||||
|
if (
|
||||||
|
!ID_PATTERN.test(buildId) ||
|
||||||
|
!EMBED_KEY_PATTERN.test(embedKey) ||
|
||||||
|
embedKeys.has(embedKey)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
embedKeys.add(embedKey);
|
||||||
|
buildEmbeds.push({ buildId, embedKey, ordinal: embedOrdinal++ });
|
||||||
|
textParts.push('[Embedded Build]');
|
||||||
|
return { type, attrs: { buildId, embedKey } };
|
||||||
|
}
|
||||||
|
if (type === 'articleImage') {
|
||||||
|
if (
|
||||||
|
!exactKeys(candidate, ['type', 'attrs']) ||
|
||||||
|
!isRecord(candidate.attrs) ||
|
||||||
|
!exactKeys(candidate.attrs, ['imageId', 'alt', 'caption'])
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const imageId = String(candidate.attrs.imageId ?? '');
|
||||||
|
const alt = String(candidate.attrs.alt ?? '')
|
||||||
|
.normalize('NFKC')
|
||||||
|
.trim();
|
||||||
|
const caption = String(candidate.attrs.caption ?? '')
|
||||||
|
.normalize('NFKC')
|
||||||
|
.trim();
|
||||||
|
if (
|
||||||
|
!ID_PATTERN.test(imageId) ||
|
||||||
|
!alt ||
|
||||||
|
[...alt].length > 300 ||
|
||||||
|
[...caption].length > 500
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
imageIds.push(imageId);
|
||||||
|
textParts.push(alt, caption);
|
||||||
|
return { type, attrs: { imageId, alt, caption } };
|
||||||
|
}
|
||||||
|
const leafTypes = ['horizontalRule', 'hardBreak'];
|
||||||
|
if (leafTypes.includes(type)) {
|
||||||
|
return exactKeys(candidate, ['type']) ? { type } : null;
|
||||||
|
}
|
||||||
|
const containerTypes = [
|
||||||
|
'doc',
|
||||||
|
'paragraph',
|
||||||
|
'heading',
|
||||||
|
'bulletList',
|
||||||
|
'orderedList',
|
||||||
|
'listItem',
|
||||||
|
'blockquote',
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
!containerTypes.includes(type) ||
|
||||||
|
!exactKeys(candidate, ['type', 'attrs', 'content'])
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
let attrs: Record<string, unknown> | undefined;
|
||||||
|
if (type === 'heading') {
|
||||||
|
if (
|
||||||
|
!isRecord(candidate.attrs) ||
|
||||||
|
!exactKeys(candidate.attrs, ['level']) ||
|
||||||
|
![2, 3].includes(Number(candidate.attrs.level))
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
attrs = { level: Number(candidate.attrs.level) };
|
||||||
|
} else if (type === 'orderedList' && candidate.attrs !== undefined) {
|
||||||
|
if (
|
||||||
|
!isRecord(candidate.attrs) ||
|
||||||
|
!exactKeys(candidate.attrs, ['start', 'type'])
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const start = candidate.attrs.start ?? 1;
|
||||||
|
const listType = candidate.attrs.type;
|
||||||
|
if (
|
||||||
|
!Number.isInteger(start) ||
|
||||||
|
Number(start) < 1 ||
|
||||||
|
Number(start) > 1_000_000 ||
|
||||||
|
(listType !== null &&
|
||||||
|
listType !== undefined &&
|
||||||
|
(typeof listType !== 'string' ||
|
||||||
|
!['1', 'a', 'A', 'i', 'I'].includes(listType)))
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const canonicalType =
|
||||||
|
typeof listType === 'string' && listType !== '1' ? listType : undefined;
|
||||||
|
attrs = {
|
||||||
|
...(Number(start) === 1 ? {} : { start: Number(start) }),
|
||||||
|
...(canonicalType ? { type: canonicalType } : {}),
|
||||||
|
};
|
||||||
|
if (!Object.keys(attrs).length) attrs = undefined;
|
||||||
|
} else if (candidate.attrs !== undefined) {
|
||||||
|
if (!isRecord(candidate.attrs) || Object.keys(candidate.attrs).length)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (candidate.content !== undefined && !Array.isArray(candidate.content))
|
||||||
|
return null;
|
||||||
|
const content: ArticleNode[] = [];
|
||||||
|
for (const child of candidate.content ?? []) {
|
||||||
|
const parsed = parseNode(child, depth + 1);
|
||||||
|
if (!parsed) return null;
|
||||||
|
content.push(parsed);
|
||||||
|
}
|
||||||
|
if (['paragraph', 'heading', 'listItem', 'blockquote'].includes(type))
|
||||||
|
textParts.push('\n');
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
...(attrs ? { attrs } : {}),
|
||||||
|
...(content.length ? { content } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const document = parseNode(value.document, 0);
|
||||||
|
if (!document || document.type !== 'doc') return null;
|
||||||
|
const parsed: ArticleDocument = {
|
||||||
|
version: ARTICLE_DOCUMENT_VERSION,
|
||||||
|
document,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
new TextEncoder().encode(JSON.stringify(parsed)).byteLength >
|
||||||
|
MAX_ARTICLE_DOCUMENT_BYTES
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const links: string[] = [];
|
||||||
|
const collectLinks = (node: ArticleNode) => {
|
||||||
|
for (const mark of node.marks ?? [])
|
||||||
|
if (mark.type === 'link' && mark.attrs?.href) links.push(mark.attrs.href);
|
||||||
|
for (const child of node.content ?? []) collectLinks(child);
|
||||||
|
};
|
||||||
|
collectLinks(document);
|
||||||
|
return {
|
||||||
|
document: parsed,
|
||||||
|
plainText: textParts.join(' ').replace(/\s+/g, ' ').trim(),
|
||||||
|
buildEmbeds,
|
||||||
|
imageIds: [...new Set(imageIds)],
|
||||||
|
links: [...new Set(links)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function articleModerationText(input: {
|
||||||
|
title: string;
|
||||||
|
deck: string;
|
||||||
|
plainText: string;
|
||||||
|
coverAlt?: string;
|
||||||
|
coverCaption?: string;
|
||||||
|
links?: string[];
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
input.title,
|
||||||
|
input.deck,
|
||||||
|
input.coverAlt,
|
||||||
|
input.coverCaption,
|
||||||
|
input.plainText,
|
||||||
|
...(input.links ?? []),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Node, mergeAttributes } from '@tiptap/core';
|
||||||
|
|
||||||
|
export const BuildEmbedNode = Node.create({
|
||||||
|
name: 'buildEmbed',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
selectable: true,
|
||||||
|
draggable: true,
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
buildId: { default: null },
|
||||||
|
embedKey: { default: null },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'article[data-build-embed]' }];
|
||||||
|
},
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'article',
|
||||||
|
mergeAttributes(HTMLAttributes, {
|
||||||
|
'data-build-embed': '',
|
||||||
|
class: 'article-editor-embed',
|
||||||
|
contenteditable: 'false',
|
||||||
|
}),
|
||||||
|
['span', {}, 'EMBEDDED BUILD'],
|
||||||
|
['strong', {}, String(HTMLAttributes.buildId ?? 'BUILD')],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ArticleImageNode = Node.create({
|
||||||
|
name: 'articleImage',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
selectable: true,
|
||||||
|
draggable: true,
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
imageId: { default: null },
|
||||||
|
alt: { default: '' },
|
||||||
|
caption: { default: '' },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'figure[data-article-image]' }];
|
||||||
|
},
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
const imageId = String(HTMLAttributes.imageId ?? '');
|
||||||
|
return [
|
||||||
|
'figure',
|
||||||
|
mergeAttributes(HTMLAttributes, {
|
||||||
|
'data-article-image': '',
|
||||||
|
class: 'article-editor-image',
|
||||||
|
contenteditable: 'false',
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
'img',
|
||||||
|
{
|
||||||
|
src: `/images/library/${encodeURIComponent(imageId)}`,
|
||||||
|
alt: String(HTMLAttributes.alt ?? ''),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'figcaption',
|
||||||
|
{},
|
||||||
|
String(HTMLAttributes.caption || HTMLAttributes.alt || 'ARTICLE IMAGE'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user