diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 48182bb..9335d5b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,6 +1,6 @@ { "name": "sql-apps-skills", "owner": { "name": "Microsoft", "url": "https://www.microsoft.com" }, - "metadata": { "description": "Independent Azure-native application development", "version": "0.1.2" }, - "plugins": [{ "name": "sql-apps", "description": "Subscription-free local Azure application workflows and diagnostics", "version": "0.1.2", "source": "./plugins/sql-apps" }] + "metadata": { "description": "Application development on Azure SQL Database and Data API Builder", "version": "0.2.0" }, + "plugins": [{ "name": "sql-apps", "description": "Build and validate Azure SQL and Data API Builder applications; delegate general Azure lifecycle work to maintained Azure skills", "version": "0.2.0", "source": "./plugins/sql-apps" }] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 27e1270..461600f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,11 +1,11 @@ { "name": "sql-apps", - "version": "0.1.2", - "description": "Build and deploy this independent Azure-native TypeScript stack.", + "version": "0.2.0", + "description": "Build and validate Azure SQL Database and Data API Builder applications; use maintained Azure skills for the general cloud lifecycle.", "author": { "name": "Microsoft SQL team (mssql)", "url": "https://github.com/microsoft" }, "homepage": "https://github.com/microsoft/sql-apps", "repository": "https://github.com/microsoft/sql-apps", "license": "MIT", "skills": "./skills/", - "keywords": ["azure", "bicep", "typescript", "data-api-builder"] + "keywords": ["azure-sql", "typescript", "data-api-builder"] } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 8b5d879..b3f3a67 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,16 +1,16 @@ { "name": "sql-apps", - "version": "0.1.2", - "description": "Build and deploy this independent Azure-native TypeScript stack.", + "version": "0.2.0", + "description": "Build and validate Azure SQL Database and Data API Builder applications; use maintained Azure skills for the general cloud lifecycle.", "author": { "name": "Microsoft SQL team (mssql)", "url": "https://github.com/microsoft" }, "homepage": "https://github.com/microsoft/sql-apps", "repository": "https://github.com/microsoft/sql-apps", "license": "MIT", "skills": "./skills/", - "keywords": ["azure", "bicep", "typescript", "data-api-builder"], + "keywords": ["azure-sql", "typescript", "data-api-builder"], "interface": { "displayName": "SQL Apps", - "shortDescription": "Build an independent Azure-native application", + "shortDescription": "Build an Azure SQL and Data API Builder application", "developerName": "Microsoft SQL team (mssql)", "category": "Coding", "capabilities": ["Read", "Write"], diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 27e1270..461600f 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,11 +1,11 @@ { "name": "sql-apps", - "version": "0.1.2", - "description": "Build and deploy this independent Azure-native TypeScript stack.", + "version": "0.2.0", + "description": "Build and validate Azure SQL Database and Data API Builder applications; use maintained Azure skills for the general cloud lifecycle.", "author": { "name": "Microsoft SQL team (mssql)", "url": "https://github.com/microsoft" }, "homepage": "https://github.com/microsoft/sql-apps", "repository": "https://github.com/microsoft/sql-apps", "license": "MIT", "skills": "./skills/", - "keywords": ["azure", "bicep", "typescript", "data-api-builder"] + "keywords": ["azure-sql", "typescript", "data-api-builder"] } diff --git a/.dockerignore b/.dockerignore index 3eb57c6..c6ccb13 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,9 @@ node_modules dist .git +.worktrees +.vscode +.azure .sql-apps .env* sql-apps.json -azure-demo-cost.json -functions/local.settings.json diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 48182bb..9335d5b 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1,6 +1,6 @@ { "name": "sql-apps-skills", "owner": { "name": "Microsoft", "url": "https://www.microsoft.com" }, - "metadata": { "description": "Independent Azure-native application development", "version": "0.1.2" }, - "plugins": [{ "name": "sql-apps", "description": "Subscription-free local Azure application workflows and diagnostics", "version": "0.1.2", "source": "./plugins/sql-apps" }] + "metadata": { "description": "Application development on Azure SQL Database and Data API Builder", "version": "0.2.0" }, + "plugins": [{ "name": "sql-apps", "description": "Build and validate Azure SQL and Data API Builder applications; delegate general Azure lifecycle work to maintained Azure skills", "version": "0.2.0", "source": "./plugins/sql-apps" }] } diff --git a/.github/workflows/azure-native-ci.yml b/.github/workflows/azure-native-ci.yml index fc9dd70..e50e7a0 100644 --- a/.github/workflows/azure-native-ci.yml +++ b/.github/workflows/azure-native-ci.yml @@ -42,4 +42,4 @@ jobs: - run: node scripts/check-application.mjs . sql/obj/Debug/model/model.xml - run: az bicep install - run: npm run infra:build - - run: npm run infra:test-demo + - run: npm run infra:test diff --git a/.gitignore b/.gitignore index c54f48c..d7237d8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,8 @@ public/auth/redirect.js .env.* !.env.example sql-apps.json -azure-demo-cost.json -functions/local.settings.json +.azure/ +.vscode/ sql/bin/ sql/obj/ examples/**/obj/ diff --git a/.grok-plugin/plugin.json b/.grok-plugin/plugin.json index 27e1270..461600f 100644 --- a/.grok-plugin/plugin.json +++ b/.grok-plugin/plugin.json @@ -1,11 +1,11 @@ { "name": "sql-apps", - "version": "0.1.2", - "description": "Build and deploy this independent Azure-native TypeScript stack.", + "version": "0.2.0", + "description": "Build and validate Azure SQL Database and Data API Builder applications; use maintained Azure skills for the general cloud lifecycle.", "author": { "name": "Microsoft SQL team (mssql)", "url": "https://github.com/microsoft" }, "homepage": "https://github.com/microsoft/sql-apps", "repository": "https://github.com/microsoft/sql-apps", "license": "MIT", "skills": "./skills/", - "keywords": ["azure", "bicep", "typescript", "data-api-builder"] + "keywords": ["azure-sql", "typescript", "data-api-builder"] } diff --git a/.kimi-plugin/plugin.json b/.kimi-plugin/plugin.json index 27e1270..461600f 100644 --- a/.kimi-plugin/plugin.json +++ b/.kimi-plugin/plugin.json @@ -1,11 +1,11 @@ { "name": "sql-apps", - "version": "0.1.2", - "description": "Build and deploy this independent Azure-native TypeScript stack.", + "version": "0.2.0", + "description": "Build and validate Azure SQL Database and Data API Builder applications; use maintained Azure skills for the general cloud lifecycle.", "author": { "name": "Microsoft SQL team (mssql)", "url": "https://github.com/microsoft" }, "homepage": "https://github.com/microsoft/sql-apps", "repository": "https://github.com/microsoft/sql-apps", "license": "MIT", "skills": "./skills/", - "keywords": ["azure", "bicep", "typescript", "data-api-builder"] + "keywords": ["azure-sql", "typescript", "data-api-builder"] } diff --git a/Dockerfile b/Dockerfile index ee571a7..2bb4edd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,6 @@ COPY dab ./dab COPY infra ./infra COPY tsconfig*.json ./ COPY src ./src -COPY functions ./functions COPY public ./public RUN npm run build && npm prune --omit=dev diff --git a/README.md b/README.md index d669425..59be862 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ SQL Apps gives you a working application foundation and guides for creating your - An equipment checkout app that records loans and returns. - An inventory app that helps a team find and update stock. - A registration app that collects and manages signups. -- A small team workflow with records, file uploads or background file processing. +- A small team workflow where signed-in members with the right role review and update shared records. These are ideas for your own application, not prebuilt templates. SQL Apps includes a separate [Todo reference app](examples/todo/README.md) if you want to try a concrete example first. @@ -57,7 +57,7 @@ For one connected setup journey, ask your assistant to use the [setup coordinato **Your application.** Work with your AI assistant to implement and test its screens and SQL behavior in its generated repository. The project factory creates the runtime foundation, not a finished domain app. The default project has no Todo screen or sample data; examples are selected explicitly. -**A useful first result.** Run the app, create a record, reload it, and make a change such as adding a field or filter. A launch URL is usable only after the intended server responds. [Run locally](docs/guides/run-locally.md) explains startup paths, scoped approvals and how to keep your data between sessions. +**A useful first result.** Run the app, sign in as a development user and see its readiness check pass, then add your first table, stored procedure and screen. A launch URL is usable only after the intended server responds. [Run locally](docs/guides/run-locally.md) explains startup paths, scoped approvals and how to keep your data between sessions. **Sharing is optional.** Azure is the cloud target. Free tier means recurring allowances, not trial credits or unlimited usage. When you are ready to share, read [Sharing your app](docs/guides/sharing.md) and [Costs and growth](docs/guides/costs.md) before choosing a cloud path. @@ -73,7 +73,7 @@ For one connected setup journey, ask your assistant to use the [setup coordinato | Share with other people | [Sharing your app](docs/guides/sharing.md) | | Find detailed commands or contribute | [Documentation index](docs/README.md) | -Under the hood, every profile uses TypeScript, Azure SQL Database and Data API builder. Profiles that need files or background jobs also use Blob Storage and Azure Functions. You do not need to choose or configure cloud services to begin building locally. +Under the hood, SQL Apps uses TypeScript, Azure SQL Database and Data API builder, with a sign-in gateway that admits only users holding the app's role. If your app later needs file storage or background jobs, add them with the maintained Azure skills rather than SQL Apps. You do not need to choose or configure cloud services to begin building locally. ## Trademarks diff --git a/application.json b/application.json index 42e6e55..f750216 100644 --- a/application.json +++ b/application.json @@ -1,4 +1,4 @@ { - "name": "sql-apps-foundation", + "name": "sql-apps", "selectedExamples": [] -} +} \ No newline at end of file diff --git a/azure-architecture-intent.example.json b/azure-architecture-intent.example.json deleted file mode 100644 index 77a63a5..0000000 --- a/azure-architecture-intent.example.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "version": 1, - "briefFingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "capabilities": [ - "static-web", - "application-api", - "visitor-sessions" - ], - "guarantees": [ - "synthetic-data-only", - "same-origin-browser" - ], - "optimization": "simplest-validated-start", - "growth": [ - "add-operational-observability" - ] -} diff --git a/azure-demo-cost.example.json b/azure-demo-cost.example.json deleted file mode 100644 index 4fd1d2d..0000000 --- a/azure-demo-cost.example.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "version": 1, - "profile": "public-demo", - "intent": "zero-azure-spend", - "sql": { "mode": "free-paused" }, - "acknowledgeFixedCharges": false -} diff --git a/azure-demo-hosting-comparison.example.json b/azure-demo-hosting-comparison.example.json deleted file mode 100644 index 82cb737..0000000 --- a/azure-demo-hosting-comparison.example.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "version": 1, - "profile": "public-demo", - "region": "eastus", - "currency": "USD", - "checkedOn": "2026-10-11", - "workload": { - "monthlyRequests": 10000, - "monthlyDataTransferGb": 1, - "activeBackendHours": 20 - }, - "current": { - "hosting": "container-apps", - "monthlyCost": 0, - "source": "https://prices.azure.com/" - }, - "candidate": { - "hosting": "static-web-apps", - "monthlyCost": 0, - "source": "https://prices.azure.com/" - }, - "liveAcceptance": false -} diff --git a/azure-demo-publication.example.json b/azure-demo-publication.example.json deleted file mode 100644 index 1d973f0..0000000 --- a/azure-demo-publication.example.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "version": 1, - "profile": "public-demo", - "securityPosture": "starter", - "hosting": "static-web-apps", - "name": "sqlapps", - "subscriptionId": "11111111-1111-4111-8111-111111111111", - "tenantId": "22222222-2222-4222-8222-222222222222", - "resourceGroup": "sql-apps-synthetic-demo", - "location": "eastus", - "staticWebAppLocation": "eastus2", - "deploymentId": "33333333-3333-4333-8333-333333333333", - "applicationName": "sqlapps-33333333-web", - "staticWebAppName": "sqlapps-demo-static", - "registryServer": "replacewithfoundationoutput.azurecr.io", - "gatewayImage": "replacewithfoundationoutput.azurecr.io/sqlapps-demo-api:0.1.0", - "dabImage": "replacewithfoundationoutput.azurecr.io/sqlapps-demo-dab:0.1.0", - "selectedApplication": "todo", - "selectedArtifact": "replace-with-the-verified-selected-artifact-path", - "sqlAdminObjectId": "44444444-4444-4444-8444-444444444444", - "sqlAdminName": "reviewed-sql-administrator", - "schemaRunnerIp": "203.0.113.10", - "cost": { - "intent": "review-paid-costs", - "acknowledgeFixedCharges": true - }, - "sqlBillingMode": "free-paused", - "sqlSku": { - "name": "GP_S_Gen5_2", - "tier": "GeneralPurpose", - "family": "Gen5", - "capacity": 2 - }, - "sqlMaxSizeBytes": 34359738368, - "sqlBackupRedundancy": "Local", - "tags": { - "environment": "synthetic-demo", - "owner": "replace-with-reviewed-owner" - } -} diff --git a/azure-demo-target.example.json b/azure-demo-target.example.json deleted file mode 100644 index fe2bdd5..0000000 --- a/azure-demo-target.example.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "profile": "public-demo", - "securityPosture": "starter", - "tenantId": "11111111-1111-4111-8111-111111111111", - "subscriptionId": "22222222-2222-4222-8222-222222222222", - "resourceGroup": "sql-apps-synthetic-demo", - "location": "eastus", - "staticWebAppLocation": "eastus2" -} diff --git a/azure-role-based-cost.example.json b/azure-role-based-cost.example.json deleted file mode 100644 index fd2a6b2..0000000 --- a/azure-role-based-cost.example.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": 1, - "profile": "role-based-data", - "intent": "zero-azure-spend", - "acknowledgeFixedCharges": false -} diff --git a/azure-role-based.example.json b/azure-role-based.example.json index ceda280..fbda500 100644 --- a/azure-role-based.example.json +++ b/azure-role-based.example.json @@ -8,10 +8,10 @@ "sqlAdminObjectId": "00000000-0000-0000-0000-000000000000", "sqlAdminName": "deployment-operator", "resourceGroup": "data-app-dev", - "location": "eastus", + "location": "westus2", "environment": "dev", "name": "data-app", - "gatewayImage": "yourregistry.azurecr.io/app-gateway:0.1.2", - "dabImage": "yourregistry.azurecr.io/app-data:0.1.2", + "gatewayImage": "yourregistry.azurecr.io/app-gateway:0.2.0", + "dabImage": "yourregistry.azurecr.io/app-data:0.2.0", "registryServer": "yourregistry.azurecr.io" } diff --git a/content/start.md b/content/start.md index 5d2d7bc..ba69ed3 100644 --- a/content/start.md +++ b/content/start.md @@ -8,7 +8,7 @@ Follow **Describe -> Run locally -> Make it yours -> Share optionally**. 1. Start with `README.md`. Open a linked guide only when the current stage needs it. 2. Ask one question at a time about who uses the app, what it stores and what people do. Summarize a small first version before editing. Do not ask me to choose frameworks or Azure services. -3. Once Node is available, run `npm run guide` and `node scripts/setup-check.mjs`. Use `node scripts/setup.mjs plan --profile --json` to find the next setup action. Stop gathering context once the plan names an action or blocker. +3. Once Node is available, run `npm run guide` and `node scripts/setup-check.mjs`. Use `node scripts/setup.mjs plan --json` (the only profile is `role-based-data`) to find the next setup action. Stop gathering context once the plan names an action or blocker. 4. Show the reviewed setup or installation plan before any download, install, license acceptance, workspace change, schema change or startup. Continue only after explicit approval. Use `setup run` or `setup resume`; do not bypass its blocker, repeat completed work, replace the SQL engine or reset saved data. 5. Build the smallest useful slice with the application skill. Use the local skill to start it. Verify one real browser action and SQL save/reload, then make and verify one useful change. diff --git a/dab/dab-config.json b/dab/dab-config.json index f5080db..c2888bf 100644 --- a/dab/dab-config.json +++ b/dab/dab-config.json @@ -19,16 +19,11 @@ } }, "entities": { - "FileJob": { - "source": { "object": "dbo.FileJobs", "type": "table" }, - "rest": { "enabled": true }, - "graphql": { "enabled": true }, - "permissions": [{ - "role": "authenticated", - "actions": [ - { "action": "read", "fields": { "exclude": ["blob_key"] }, "policy": { "database": "@item.owner_oid eq @claims.oid" } } - ] - }] + "AppReady": { + "source": { "object": "dbo.AppReady", "type": "stored-procedure" }, + "rest": { "enabled": true, "methods": ["get"] }, + "graphql": { "enabled": false }, + "permissions": [{ "role": "AppUser", "actions": ["execute"] }] } } -} +} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index fae9787..6fd1ade 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,34 +7,31 @@ Choose a guide for what you want to do. Command details and contributor contract 1. [Get started](guides/getting-started.md): prepare your computer and get SQL preview access. 2. [Build your app](guides/build-your-app.md): describe an idea, build a first version and make it yours. 3. [Run locally](guides/run-locally.md): choose a startup path, stop and resume. -4. [Sharing your app](guides/sharing.md): choose access and understand current cloud options. -5. [Costs and growth](guides/costs.md): local costs, recurring free tiers and paid growth. +4. [Sharing your app](guides/sharing.md): use maintained Azure skills for cloud lifecycle work while preserving SQL/DAB acceptance. +5. [Costs and growth](guides/costs.md): understand local costs and route Azure estimates/analysis to the Azure Cost plugin. To try something already implemented, use the [Todo reference app](../examples/todo/README.md). ## Command and configuration reference - [Independent projects](reference/projects.md): project manifest, packaged tooling and foundation boundaries. -- [Local runtime](reference/local-development.md): services, workspace settings, diagnostics and recovery. +- [Local runtime](reference/local-development.md): the role-based-data startup stages, workspace ports, diagnostics and recovery. - [Setup coordinator](reference/setup.md): connected first-run planning, approved tool installation and safe continuation. - [Remember local permissions](reference/local-consent.md): one setup approval covering restore, build, workspace, startup and SQL terms, reusable grants and revocation. - [Manage projects](reference/projects.md): create independent application repositories under the user-level SQL Apps project root. - [Authentication and application access](reference/authentication.md): account states, local preview, real Entra opt-in, access permissions and current/future hosting boundaries. -- [Role-based-data profile](reference/role-based-data.md): SQL/DAB/browser startup, application-role authorization, matching costs and private SQL Azure deployment. +- [Role-based-data profile](reference/role-based-data.md): SQL/DAB/browser startup, application-role authorization and private SQL Azure deployment. - [Copilot plugin](reference/copilot-plugin.md): installation, binding and troubleshooting. - [Guide command](reference/guide.md): saved brief and checkpoint format. -- [Demo cost review](reference/demo-cost.md): offline cost command, SQL billing settings and measured hosting comparison. - [Monitor usage and growth](reference/monitor-growth.md): free Database Hub preview setup, read-only SQL diagnostics and capacity decisions. -- [Public-demo publishing candidate](reference/demo-deployment.md): proposed publish/status commands, diagnostics, template contracts and live acceptance. -- [Authenticated Azure deployment](reference/deployment.md): full infrastructure deployment commands. +- [Authenticated Azure deployment](reference/deployment.md): retained role-based-data deployment commands for existing SQL Apps deployments. ## Contribute and maintain - [Contributing](../CONTRIBUTING.md): development checks and pull requests. - [Agent guidance evaluations](maintainers/agent-evaluations.md): behavior scenarios for prompt, skill and workflow-boundary changes. - [Application boundaries](maintainers/application-boundary.md): keep reference examples out of delivered applications. -- [Hosting decision: Static Web Apps candidate](maintainers/hosting-decision.md): why public demos may split static hosting from the linked API, and the gates before changing the default. -- [Operations and release checks](maintainers/operations.md): authenticated deployment recovery and production checks. +- [Legacy operations and release checks](maintainers/operations.md): recovery and production checks for existing custom deployments. - [Naming transition](maintainers/naming-transition.md): migration notes for existing development checkouts. Temporary implementation plans and session notes belong in ignored `docs/superpowers/` or session storage. They are not product guidance and must not be committed as authoritative documentation. diff --git a/docs/guides/build-your-app.md b/docs/guides/build-your-app.md index ae0f3b8..d98196a 100644 --- a/docs/guides/build-your-app.md +++ b/docs/guides/build-your-app.md @@ -33,11 +33,11 @@ If you know SQL, bring your tables and relationships into the discussion. If not Complete [Get started](getting-started.md) before downloading dependencies or launching the app. -Choose the capability profile before writing code. After you save the agreed scope, `npm run guide` reports a `suggestedProfile`: `role-based-data` for data-only apps and `foundation` when files or background jobs were agreed. The [role-based-data profile](../reference/role-based-data.md) provides SQL/DAB/browser startup with trusted application-role forwarding and a matching paid private-SQL Azure path. Domain screens, procedures and actual workflow acceptance still need implementation. The full foundation retains files/jobs; do not add excluded services or switch to anonymous access just to fit a template. +SQL Apps builds data apps with one profile, `role-based-data`; `npm run guide` reports it as the `suggestedProfile`. The [role-based-data profile](../reference/role-based-data.md) provides SQL/DAB/browser startup with trusted application-role forwarding and a free-limit, scale-to-zero Azure POC path. The starter is a minimal status page that calls a read-only readiness procedure; domain tables, procedures, screens and actual workflow acceptance still need implementation. If the app later needs file storage or background jobs, add them with the maintained Azure skills outside SQL Apps; do not switch to anonymous access just to fit a template. Then ask: -> Implement the agreed screens and SQL behavior using the suggested SQL Apps profile. Explain the files you change and help me run the app locally. +> Implement the agreed screens and SQL behavior in this SQL Apps role-based-data project. Explain the files you change and help me run the app locally. Your AI assistant can help write your frontend, API and schema; SQL Apps does not generate a complete domain app automatically. Review its changes and use the startup path for your application in [Run locally](run-locally.md). Trying the Todo reference is optional, not a prerequisite. diff --git a/docs/guides/costs.md b/docs/guides/costs.md index 2fd99b1..833931a 100644 --- a/docs/guides/costs.md +++ b/docs/guides/costs.md @@ -2,58 +2,37 @@ ## Start locally -Running SQL Apps on your computer does not create an Azure usage bill. You still need the required tools, downloads and SQL private-preview access. Docker Desktop and your chosen AI provider have their own licensing, account and pricing conditions. - -You do not need to deploy to Azure to build, test or keep using a local app. - -When adding capabilities, consider their future costs without choosing cloud infrastructure yet. Files need storage, background work needs compute, and AI calls can add usage charges. Do the detailed pricing review when you decide to share, not before you have a useful local app. - -## Understand free tier before sharing - -**Free tier means recurring monthly allowances, not a time-bound trial.** Allowances have limits and eligibility requirements; they are not unlimited hosting. - -Azure SQL and Container Apps have free allowances, but the current demo templates also use a paid container registry and managed networking. Scaling the app to zero does not remove those charges. The authenticated deployment uses additional paid services. - -The Static Web Apps candidate does not remove that stack. Static Web Apps Standard serves the static website, while one linked Container App still runs the Node API and internal DAB. ACR, networking, SQL and Container Apps can still charge, and Standard adds its own plan. It is therefore likely not cheaper and must not be presented as a cost-saving default without current, same-basis evidence. - -## Lowest-cost POC path - -The lowest-cost path is not "pick any free tier and deploy". For this repo, a zero-dollars or near-zero-dollars proof-of-concept should stay local unless a reviewed design is explicit about which services are in scope, what is still paid, and which grants are being consumed. - -A simplified App Service or Static Web Apps candidate may be cheaper than the current Container Apps default for specific workloads, but it does not remove the need to review: - -- free-tier eligibility and monthly activation limits for Azure SQL, -- App Service plan choice and whether the chosen plan supports the application boundary, -- storage, networking, container registry and observability costs that still exist even when the app is idle, -- and whether a DB-free or local-first approach is the correct boundary for a true zero-cost proof-of-concept. - -This repo keeps the production-safe default because the app's real deployment boundary is a managed identity, private networking, SQL access, API validation and DAB hosting. Those concerns are more important than a one-line claim that "App Service free tier exists". The default remains production-oriented until a reviewed cost comparison proves a lower-cost path is equivalent. - -If zero Azure spending is a requirement, stay local until you have reviewed a hosting design that meets it. SQL Apps does not currently provide a guaranteed zero-cost deployment. - -Before creating resources, select the approved capability/access profile, then review the target region, subscription eligibility, fixed charges, expected usage and cleanup plan. The [offline demo cost command](../reference/demo-cost.md) identifies known demo charges without contacting Azure; it is demo-only, not an authenticated app cost model, price quote or spending cap. Role-authorized data-only apps use the matching [role-based-cost review](../reference/role-based-data.md#profile-specific-azure-preparation); do not price an older stack or substitute the full foundation as though it were the agreed app. - -For the hosting candidate, populate `azure-demo-hosting-comparison.json` with dated regional/account pricing and measured usage, then run `npm run azure -- hosting-compare azure-demo-hosting-comparison.json`. Use the same region, currency, request count, transfer and active-backend hours for both choices. Container Apps remains recommended if Static Web Apps costs more or live acceptance has not passed; no command changes the default automatically. - -## What happens when an allowance runs out? - -The free-tier SQL configuration pauses at its monthly limit rather than automatically switching to paid usage. That can make the app unavailable until the next calendar month. Idle pause and resume can also introduce delays. - -Other Azure resources may charge for usage beyond their allowances. Budget alerts notify you; they do not stop spending. Review actual subscription-wide usage, not just visitor counts. - -## Grow when the app needs it - -Start with visibility, not an upgrade. After sharing an app on Azure SQL, you can use **Database Hub in Microsoft Fabric (preview)** to monitor it in place. The experience is available with a Fabric Free license; no Fabric capacity or separate Database Hub license is required to get started, and enabling its SQL performance monitoring has no extra cost. Your Azure resources still have their normal charges. - -Follow [Monitor usage and decide when to grow](../reference/monitor-growth.md) to connect your database, check free-tier headroom, and investigate resource pressure with read-only SQL queries. - -1. **Observe:** check remaining SQL free allowance, performance during a real app action, and subscription-wide costs. -2. **Improve:** investigate expensive queries, repeated polling or background work before adding capacity. -3. **Decide:** consider paid capacity when measured limits, unacceptable delays or availability/recovery needs justify it. -4. **Verify:** repeat the same app action after an approved change and compare performance and cost. - -Popularity is a reason to review usage, not a user-count threshold for upgrading. Public visitor-session creation has no app-level admission throttle or active-session cap; existing per-session data and mutation limits still apply. Automated traffic consumes resources too. - -You choose whether and when to pay for more capacity. SQL Apps does not change billing or upgrade automatically. Monitoring is a separate opt-in setup using Database Hub or the Azure portal, not something the SQL Apps commands configure for you. - -For current allowance figures, template settings and monitoring steps, use the [cost reference](../reference/demo-cost.md). For deployment choices, read [Sharing your app](sharing.md). +Building and running SQL Apps locally does not create an Azure usage bill. You +still need the required tools, downloads, and Azure SQL Database container +private-preview access. Docker Desktop and your chosen AI provider have their +own licensing, account, and pricing conditions. + +You do not need to deploy to Azure to build, test, or keep using a local app. + +## Review Azure costs when you choose to share + +Use the maintained **Azure Cost** plugin for current regional and account-aware +price estimates, actual spend analysis, forecasts, optimization, and governance. +Use `azure-app-onboard` when comparing Azure architectures. A cost estimate is +not a quote, spending cap, deployment approval, or proof that a resource is +eligible for a free offer. + +For database-specific provisioning and workload sizing, route to +`provision-azure-sql-db` or `provision-hyperscale`. For SQL Apps deployment, +follow the maintained Azure Skills prepare -> validate -> deploy workflow in +[Sharing your app](sharing.md). Preserve SQL Apps' requirements for managed +identity, least-privilege database grants, DAB exposure, private networking, +and live application acceptance. + +SQL Apps does not ship its own price calculators. Ask the Azure Cost plugin +to estimate the planned resource inventory before approving any deployment, +including fixed charges such as the container registry, private endpoints and +managed networking that free SQL or compute allowances do not cover. + +## Keep the SQL/DAB application contract + +Do not weaken authorization, SQL grants, network isolation, or data retention +to reduce a cost estimate. Prefer removing optional capabilities from the +agreed application scope. Before applying any paid deployment, review the +actual target, resource inventory, expected use, cleanup/retention plan, and +account-specific estimate with the operator. diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 9c9cfbb..29191de 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -39,10 +39,10 @@ An AI assistant is optional for running the application. Guided app building can | --- | --- | | Node.js 22 or 24 with npm | Build and run the application and setup check. | | .NET SDK 8 or later | Build and publish the SQL schema. Choose SDK, not just Runtime. | -| Docker with Linux containers | Run local SQL and application services. | +| Docker with Linux containers | Run local SQL and Data API builder containers. | | Azure SQL Database container private-preview access | Download the SQL image for first use, unless a usable image is already cached or a current container is available. | -Initial npm packages, SQL tools and container images require internet and disk space. Docker Desktop has [license conditions](https://docs.docker.com/subscription/desktop-license/) and is not free for every organization. No Azure CLI, host DAB, host sqlcmd or Functions Core Tools installation is needed. +Initial npm packages, SQL tools and container images require internet and disk space. Docker Desktop has [license conditions](https://docs.docker.com/subscription/desktop-license/) and is not free for every organization. No Azure CLI, host DAB or host sqlcmd installation is needed. ### Windows @@ -105,24 +105,23 @@ You do not need to acknowledge an unchanged reviewed document every session. [Re Once your app scope is agreed, use the dependency-free [setup coordinator](../reference/setup.md): ```powershell -node scripts\setup.mjs plan --profile role-based-data --json +node scripts\setup.mjs plan --json ``` -Use `role-based-data` only for the configured SQL/DAB/browser domain profile; -use `foundation` for the agreed file/job foundation. Neither generates screens -or selects an example. The report separates prerequisites, dependencies, -current build, workspace, startup permissions and application acceptance. +`role-based-data` is the only profile, so `--profile role-based-data` is +optional. Setup does not generate screens or select an example. The report +separates prerequisites, dependencies, current build, workspace, startup +permissions and application acceptance. `setup plan` installs nothing. Install missing system tools with separate `setup install-plan ` reviews. Everything else is one approval: -`setup approval --profile ` lists restore, build, workspace, startup -and the SQL terms line; after you approve, `setup approve` records it and -`setup run` continues. Licenses, preview credentials and required restarts -remain human steps. See the reference for platform support and verification limits. +`setup approval --profile role-based-data --workspace ` lists +restore, build, workspace, startup and the SQL terms line; after you approve, +`setup approve` records it and `setup run` continues. Licenses, preview +credentials and required restarts remain human steps. See the reference for +platform support and verification limits. - **Build your own app:** follow [Build your app](build-your-app.md). - **Try an implemented example:** follow the [Todo reference guide](../../examples/todo/README.md). -- **Explore the file-processing foundation:** follow [Run locally](run-locally.md). +- **Start, stop or resume the app:** follow [Run locally](run-locally.md). -The paths use the same tools but start different capabilities. You do not need to run the full file-processing demonstration before building a data-only app. - -For service diagnostics and data-preserving recovery, see the [local runtime reference](../reference/local-development.md). +For diagnostics and data-preserving recovery, see the [local runtime reference](../reference/local-development.md). diff --git a/docs/guides/run-locally.md b/docs/guides/run-locally.md index 9fc43ce..4753602 100644 --- a/docs/guides/run-locally.md +++ b/docs/guides/run-locally.md @@ -6,23 +6,21 @@ Run a browser app backed by SQL on your own computer. No Azure subscription is r | What you are working on | Startup path | | --- | --- | -| Your own data-only app | Follow [Build your app](build-your-app.md) and the data-only steps below; do not start excluded file/job services. | -| Your own app with files/jobs | Use the foundation startup below after implementing its screens, schema and authorization. | +| Your own app | Follow [Build your app](build-your-app.md), then the startup below. It starts SQL, Data API builder (DAB) and the browser gateway. | | The included Todo reference | Follow the [Todo guide](../../examples/todo/README.md). It starts only SQL, DAB and the browser/API. | -| The foundation's file-processing demonstration | Use the startup below. It also starts local storage and Functions. | -The foundation is not a finished inventory or registration app. Its default browser demonstrates files and jobs; your domain screens need to be implemented. +SQL Apps has one profile, [role-based-data](../reference/role-based-data.md): Azure SQL Database plus DAB, with a dedicated application role and a read-only readiness procedure. The starter is a minimal status page; your domain screens need to be implemented. File storage and background jobs are outside SQL Apps; add them with the maintained Azure skills. ## Approvals and readiness The [setup coordinator](../reference/setup.md) connects these steps without -requiring a desktop executable. Run `npm run setup -- plan --profile --json` +requiring a desktop executable. Run `npm run setup -- plan --json` before changing anything. After explicit approvals, `run` or `resume` rechecks -the checkout, skips current dependencies/builds and launches only that profile. +the checkout, skips current dependencies/builds and launches the app. It never records grants automatically. The existing individual commands below remain available; selected examples retain their separate application workflow. -Before the first approval, explain what stays separate: tool installations (only if missing) and anything in Azure. Everything else for local startup is one approval: `npm run setup -- approval --profile ` lists restore, build, the workspace port block, image downloads, SQL startup, schema, services and the SQL terms line, with exclusions. After explicit approval run the printed `setup approve ... --approval `, then `setup run`. Editing your application's SQL, DAB configuration or data profile reuses the approval; launcher, port or terms changes show a new id to review. +Before the first approval, explain what stays separate: tool installations (only if missing) and anything in Azure. Everything else for local startup is one approval: `npm run setup -- approval --profile role-based-data --workspace ` lists restore, build, the workspace port block, image downloads, SQL startup, schema, services, the read-only SQL acceptance check and the SQL terms line, with exclusions. After explicit approval run the printed `setup approve ... --approval `, then `setup run`. Editing your application's SQL, DAB configuration or `role-based-data.json` reuses the approval; launcher, port or terms changes show a new id to review. [Remembered local permissions](../reference/local-consent.md) store that approval per checkout and workspace across sessions. Grants are revocable, never global, and do not cover tool installations or Azure deployment. @@ -32,26 +30,13 @@ Startup passes `ACCEPT_EULA=Y`; there is no chat dialog. Review the [container d Use the validation skill for current evidence and reporting. A proposed port/URL is unavailable until the intended server responds and readiness checks pass. Only then open/publish the launch URL. Verify the agreed browser save/reload action and SQL persistence separately; tests or a successful build do not replace it. If launch is blocked, the task is blocked, not complete. -Initial launch requires every dependency included in the selected profile. The foundation checks authorized SQL/DAB access, Blob/Queue metadata and authenticated Functions echo; data-only profiles do not require excluded services. A matching gateway that responds but is not ready is a setup blocker, not a launch success. Setup does not replace it automatically. +Initial launch requires the configured readiness procedure (default `/api/AppReady`) to succeed through DAB with the required role (default `AppUser`). A matching gateway that responds but is not ready is a setup blocker, not a launch success. Setup does not replace it automatically. An early synthetic or mock-only shell is useful progress, not proof of SQL connectivity. Approve the visual direction before substantial UI work and review the actual desktop/mobile render when feasible. -## Run a data-only domain app +## Start the app -For a role-authorized domain app, use the [role-based-data profile](../reference/role-based-data.md): configure its dedicated application role and read-only readiness procedure, then use `npm run local -- role-based-app` after scoped build/workspace/startup approval. It starts SQL/DAB/browser only; `role-based-serve` resumes it. The product offers **Open local preview**, not a persona selector. Test identities are on a separate developer page; real Entra sign-in is an explicit local opt-in described in [Authentication and application access](../reference/authentication.md). - -For advanced SQL-only work without this role-based profile, the existing individual steps remain available, with the scoped approvals above: - -1. Restore/build as needed and select the workspace using `workspace-plan` and approved `workspace-init`. -2. Use `npm run local -- start-sql`, or `verify ` for explicitly approved reuse. -3. Use `npm run local -- init` and `npm run local -- data`. -4. Start the gateway with `npm run local -- serve-sql`. - -Pass the same optional SQL container name throughout when selecting a non-default container. `serve-sql` omits file/job adapters but does not generate domain screens, custom application roles or app-specific readiness checks. Remove excluded controls and verify the application's actual browser action, authorization and SQL save/reload. Do not run `app`, `services` or file-processing acceptance for this scope. The selected synthetic reference is not a role-authorized substitute. - -## Start the foundation - -From the project folder: +Configure the dedicated application role and read-only readiness procedure in `role-based-data.json` as described in [role-based-data](../reference/role-based-data.md). From the project folder: ```powershell npm ci @@ -66,19 +51,19 @@ npm run local -- workspace-init npm run local -- app ``` -Workspace selection keeps this checkout's service names, ports and saved state separate. Startup downloads missing images, accepts the SQL container EULA, publishes the project schema and launches services. Read the [local runtime reference](../reference/local-development.md) before reusing an existing stack or database. +Workspace selection keeps this checkout's service names, ports and saved state separate. `app` (alias `role-based-app`) runs four stages: `start-sql`, `init`, `data` and `serve`. It downloads missing images, accepts the SQL container EULA, publishes the project schema, starts DAB and launches the browser gateway. Read the [local runtime reference](../reference/local-development.md) before reusing an existing stack or database. The workspace and startup commands above are covered by the single `setup approval` bundle; with the individual commands you can instead record scopes from their [consent plans](../reference/local-consent.md). Use the guarded `consent run restore`/`consent run build` paths for assistant-led work; raw package commands are not permission bypasses. -After the intended server responds and `/health/ready` passes, open its reported browser URL and select **Open local preview**. For the approved foundation file/job demonstration, upload a small text file and select **Process file**. The completed job shows byte count and SHA-256. Test two-user ownership separately by opening the reported local-only developer page in the same tab, switching the test identity, and returning to confirm that the other user's files and jobs are absent. +After the intended server responds and `/health/ready` passes, open its reported browser URL and select **Open local preview**. The product offers **Open local preview**, not a persona selector. To check authorization, open the reported local-only developer page in the same tab and switch between Development Alice (has the required role) and Development Bob (does not). Bob receives HTTP 403 for data requests; `/auth/me` reports whether access is allowed. Real Entra sign-in is an explicit local opt-in described in [Authentication and application access](../reference/authentication.md). Preview identities are simulations, not production sign-in. Keep the local app and DAB on loopback; do not expose them to other computers. See the [authentication boundaries](../reference/authentication.md#three-distinct-boundaries) for how preview, real local Entra sign-in and cloud backend identity differ. -For your own application, try its main action and reload to check persistence. Use test data, not sensitive production records. +For your own application, try its main action and reload to check persistence. Use test data, not sensitive production records. The selected synthetic reference is not a role-authorized substitute. ## Stop and return later -Press **Ctrl+C** in the browser-server terminal. SQL and storage data remain saved; their containers may still be running. +Press **Ctrl+C** in the browser-server terminal. SQL data remains saved; the SQL and DAB containers may still be running. To resume against those services: @@ -86,27 +71,27 @@ To resume against those services: npm run local -- serve ``` -To stop the foundation worker and storage without deleting their data: +To stop the DAB container without deleting data: ```powershell -npm run local -- stop-services +npm run local -- stop ``` -After stopping foundation services, use `app` to start them again. Role-authorized data-only apps resume with `role-based-serve`; advanced SQL-only work uses `serve-sql` against running SQL/DAB, not foundation `serve` or `app`. Selected-reference commands have their own stop/resume steps in the Todo guide. Do not delete volumes or saved credentials to restart an app. In preview mode, open local preview again after a gateway restart; SQL and storage data persist independently of the session. +After `stop`, use `app` (or `data`, then `serve`) to start again. Selected-reference commands have their own stop/resume steps in the Todo guide. Do not delete volumes or saved credentials to restart an app. In preview mode, open local preview again after a gateway restart; SQL data persists independently of the session. -`serve` only resumes the gateway against existing services. It tries readiness three times, with one-second pauses and a four-second shared deadline per attempt, then reports the named failure without listening or publishing a ready URL. It never starts or restarts a dependency. Inspect the failure before separately approved recovery; full `app`/`services` startup can recreate owned services and is not a diagnostic retry. +`serve` (alias `role-based-serve`) only resumes the gateway against existing SQL and DAB. It tries readiness three times, with one-second pauses and a four-second shared deadline per attempt, then reports the named failure without listening or publishing a ready URL. It never starts or restarts a dependency. Inspect the failure before separately approved recovery; full `app` startup recreates the owned DAB container and is not a diagnostic retry. -If a worker fails after successful launch, keep the gateway running. Readiness becomes HTTP 503 while liveness remains HTTP 200, and queued jobs remain durable. Submission can still work while SQL and storage are available; configured controls do not guarantee that every service is currently healthy. Readiness recovers on the next successful probe without a gateway restart. +If SQL or DAB fails after a successful launch, keep the gateway running. Readiness becomes HTTP 503 while liveness remains HTTP 200. Readiness recovers on the next successful probe without a gateway restart. ## When something fails -Read the named failed startup stage before retrying. Common first checks: +Read the named failed startup stage (`start-sql`, `init`, `data` or `serve`) before retrying. Common first checks: - **Tool not found:** reopen the terminal after installation and run the setup check again. - **Docker unavailable:** start its Linux-container engine. - **SQL image unauthorized:** complete preview access in your own terminal. - **Port occupied:** identify the existing app; do not stop an unrelated process. -- **SQL or processing error:** use the [local runtime reference](../reference/local-development.md) for the affected service's diagnostics and recovery. -- **Readiness names `data`, `blob`, `queue` or `functions`:** inspect that dependency before requesting recovery. Anonymous DAB aggregate health is not proof that the authorized SQL query failed. +- **SQL error:** use the [local runtime reference](../reference/local-development.md) for diagnostics and recovery. +- **Readiness names `data`:** inspect SQL and DAB before requesting recovery. Anonymous DAB aggregate health is not proof that the authorized SQL procedure failed. -For deeper checks, use the [local runtime reference](../reference/local-development.md). Test commands that create fixtures or restart services are described there so you can choose when to run them. +For deeper checks, use the [local runtime reference](../reference/local-development.md). Test commands that create sessions or restart services are described there so you can choose when to run them. diff --git a/docs/guides/sharing.md b/docs/guides/sharing.md index cfb1a19..7ab8d10 100644 --- a/docs/guides/sharing.md +++ b/docs/guides/sharing.md @@ -2,59 +2,37 @@ Before moving a local app online, decide **who should use it** and **what information it contains**. A public URL is not a substitute for access control. +For new cloud deployments, SQL Apps owns the Azure SQL/DAB application contract and acceptance checks; maintained Azure tooling owns service selection, infrastructure, cost review, predeployment validation, deployment, and cloud diagnostics. Use the Azure Skills `azure-app-onboard` when the architecture is open, then `azure-prepare` -> `azure-validate` -> `azure-deploy`. Use the `azure-cost` plugin for Azure estimates and cost analysis, and `azure-diagnostics` for production incidents. For SQL-specific identity, schema, and DAB requirements, route to `build-app-on-azure-sql`, `entra-id-auth`, and `dab-rest-and-graphql`. + +The `npm run azure` workflow documented below remains supported for existing role-based-data deployments; do not use it as the default for new projects. External deployment success is not SQL/DAB application acceptance: verify the deployed identity, DAB role forwarding, intended permissions, and a real browser-to-SQL persistence/reload workflow with `sql-apps-validation`. + ## Choose the right path | You want to... | Current option | | --- | --- | | Keep using the app on your computer | Stay local. No cloud deployment is needed. | -| Let people try a public app with synthetic data | Review the [public-demo publishing candidate](../reference/demo-deployment.md). Its proposed guided commands are not yet the default deployment path. | -| Share an authenticated app with the full foundation capabilities | Full Azure commands require Entra setup, paid Functions/storage/Key Vault/private-network infrastructure and a privately connected SQL deployment runner. | -| Share a role-authorized data-only domain app | Use the [role-based-data profile](../reference/role-based-data.md): matching cost review, two-image artifacts, authorized identity/assignment and private SQL deployment. Paid infrastructure and live authorization/browser acceptance are still required. | +| Prepare a new app for Azure | Use the maintained Azure Skills onboarding and prepare/validate/deploy flow above, with SQL Apps requirements and acceptance. | +| Estimate cost or inspect current spend | Use the `azure-cost` plugin; treat estimates as estimates, not a spending cap or deployment approval. | +| Diagnose an Azure production incident | Use `azure-diagnostics`; use SQL Apps validation for the app-specific SQL/DAB contract. | +| Continue an existing SQL Apps deployment | Review the [deployment reference](../reference/deployment.md) and [role-based-data profile](../reference/role-based-data.md); do not mix its state with `azd` deployments. | Do not put real or sensitive information in the anonymous synthetic-data reference. -## Review costs first - -Select the agreed capability/access profile before choosing a cost command. Read [Costs and growth](costs.md). The goal is free-tier-first, but current templates include recurring charges. A cost review is a decision step, not permission to spend or deploy. - -For a public-demo cost review after building: - -```powershell -npm run azure -- demo-cost azure-demo-cost.example.json -``` - -The zero-spend example reports known fixed-charge blockers and exits 2. This is expected; it does not create resources. Its output is demo-only, not a price estimate for an authenticated app. Review the authenticated application's actual resources/prices separately; missing estimates are unknown, not zero. See the [cost command reference](../reference/demo-cost.md). +## Existing SQL Apps deployments -## Prepare an authenticated deployment +The `npm run azure` commands deploy the role-based-data profile: private Azure SQL, an internal DAB with managed-identity database grants, and live SQL Apps acceptance. They remain supported for existing deployments. Follow the [deployment reference](../reference/deployment.md) and [role-based-data profile](../reference/role-based-data.md); you will review your Azure target, identities, images, networking and schema before creating resources. -If this is the path you need, follow the [deployment reference](../reference/deployment.md). You will review your Azure target, identities, images, networking, schema and costs before creating resources. +Do not change authorized access to an anonymous demo to fit a template. If the app needs file storage or background jobs, add them with maintained Azure skills such as `azure-storage`, outside SQL Apps. The `identity` command creates only the configured human role and `role-based-assign` manages explicitly approved assignments. Verify token claims and trusted DAB forwarding in the real environment. -If the approved app excludes files/jobs, select `role-based-data` rather than expanding to the full template or changing authorized access to an anonymous demo. Its `identity` command creates the configured human role and `role-based-assign` manages explicitly approved assignments; the foundation profile still creates `Function.Invoke`. Verify token claims and trusted DAB forwarding in the real environment. +To update an existing deployment after changing SQL, DAB or configuration, run `artifacts` and then `deploy`; see [Updating a deployment](../reference/deployment.md#updating-a-deployment). Existing-resource discovery must not change the deployment subject. Older matching Azure resources are potential collisions and remain untouched. Keep the new checkout and its artifacts as the source unless reuse is explicitly requested and separately reviewed. Preparation is not permission to create, modify or delete resources. -After an approved deployment, test sign-in, each user's access, saved data, selected capabilities and recovery in that environment. Local tests and template compilation do not prove those cloud behaviors. - -For the evolving minimal public-demo path, use [public-demo publishing candidate](../reference/demo-deployment.md). It is a technical reference, not a shortcut around approval, cost and live-acceptance gates. - -## Public-demo hosting candidate - -For a public synthetic demo, the Static Web Apps candidate looks like one app at **one public URL**. **Static Web Apps Standard hosts the static website** and forwards `/api/*` to **one linked Container App**. That Container App runs the protected Node API and an internal **Data API Builder (DAB)** sidecar; DAB has no public ingress and connects to Azure SQL with managed identity. - -This design does **not** use App Service for the website. It also does not use Static Web Apps Database Connections: that feature is retired, so `/data-api` is not part of the design. The browser uses only the Static Web Apps hostname and same-origin `/api/*` routes. - -Static Web Apps Standard is **not automatically cheaper**. It adds a resource while Container Apps, ACR, networking and SQL remain. The current Container Apps public-demo path remains the default until a dated, same-basis `hosting-compare` result and the complete live acceptance checklist both pass. A successful comparison reports a candidate; it never changes the default automatically. - -The candidate command interface is: +After an approved deployment, test sign-in, each user's access, saved data and recovery in that environment. Local tests and template compilation do not prove those cloud behaviors. -```powershell -npm run azure -- demo-publish azure-demo-publication.json -npm run azure -- demo-publish azure-demo-publication.json --approve -npm run azure -- demo-status azure-demo-publication.json -npm run azure -- hosting-compare azure-demo-hosting-comparison.json -``` +## Public synthetic demos -These commands are the proposed public-demo journey, not permission to spend and not guidance for real data. Keep using the private, advanced [role-based-data](../reference/role-based-data.md) and [full-foundation](../reference/deployment.md) paths for their intended authenticated applications. +Selected example applications build a hardened `public-demo` container (`npm run app:build -- public-demo`) with visitor sessions and synthetic data only. SQL Apps no longer ships its own public-demo publisher or hosting comparison: deploy that artifact through the maintained Azure Skills, keep DAB internal with managed-identity access to Azure SQL, and verify direct backend access is denied before sharing the URL. ## Accounts before publishing @@ -62,6 +40,6 @@ Follow **Build -> Sign in -> Give access -> Publish** and the [authentication an Giving someone access requires a separately approved application-role assignment and the appropriate DAB permissions and SQL grants. Verify the delegated token, role forwarding and any owner restrictions, not just the ability to sign in. Browser role editing, guest invitations and an administration console are not implemented. -Container Apps is the current supported hosting implementation and remains the default. Static Web Apps Standard is the public synthetic-demo candidate described above; App Service is not used for its website. SWA CLI only mocks authentication and is not a prerequisite for real local Entra sign-in. +Container Apps is the hosting implementation used by the retained deployment. SWA CLI only mocks authentication and is not a prerequisite for real local Entra sign-in. Changing hosting does not remove private SQL, networking, selected-service costs or identity requirements. Any new publishing path needs its own deployment and rollback plan, cost review, spending approval and explicit authorization before Azure changes. diff --git a/docs/maintainers/hosting-decision.md b/docs/maintainers/hosting-decision.md deleted file mode 100644 index ab8e4bb..0000000 --- a/docs/maintainers/hosting-decision.md +++ /dev/null @@ -1,63 +0,0 @@ -# Hosting decision: Static Web Apps candidate for public demos - -## Decision - -Public synthetic-data applications may use Azure Static Web Apps Standard for -static assets and one linked Azure Container App for `/api/*`. The Container App -keeps the Node API and an internal DAB sidecar. Role-based-data and -full-foundation deployments remain unchanged. - -The current Container Apps public-demo path remains the default until both the -measured hosting comparison and live acceptance gates pass. Static Web Apps is -not assumed to be cheaper: Standard adds a resource while SQL, Azure Container -Registry, networking, Container Apps, the Node API and DAB remain. Likewise, a -free App Service or free Azure SQL allowance does not make the full deployment -zero-cost. The default stays production-safe and cost-aware until a reviewed, -measured comparison proves a lower-cost path is equivalent. - -## Why DAB is still self-hosted - -[Static Web Apps Database Connections][database-retirement] retired on -November 30, 2025. Do not use the retired `/data-api` integration. DAB runs in the -Container App and connects to Azure SQL with managed identity. Microsoft's -[DAB deployment guide][dab-container-apps] documents DAB hosting on Container -Apps. - -## Trust boundary - -The browser calls only same-origin `/api/*`. Static Web Apps links that prefix to -the Node API. The Node API owns HttpOnly visitor sessions, request validation, -procedure allowlisting and response validation. DAB is reachable only from the -Node API over loopback and is never the public or linked backend. - -Linking installs the `Azure Static Web Apps (Linked)` identity provider on the -Container App. That platform boundary, not the caller-controlled `Origin` -header, denies direct backend requests. Publication verifies the boundary by -calling the Container App URL directly and requiring HTTP 401 or 403. - -## First-party constraints - -Microsoft's [API overview][api-overview], [Container Apps linked-backend -guide][linked-container-app] and [hosting-plan comparison][swa-plans] establish -the applicable boundaries: - -- A linked Container App requires Static Web Apps Standard. -- Only routes under `/api` are proxied to the linked backend. -- Each Static Web Apps environment can link only one backend type. -- Each API request has a maximum duration of 45 seconds. -- Network-isolated backends are unsupported. -- Bring-your-own linked backends are unsupported in pull-request environments. - -The [SWA CLI deployment guide][swa-cli-deploy] describes static deployment; it -does not replace provisioning or operating the linked backend. - -## Default promotion gate - -Change the default only in a reviewed commit after: - -1. `hosting-compare` accepts current regional and account evidence and reports - the Static Web Apps candidate no more expensive for the agreed workload. -2. Live acceptance passes static delivery, same-origin cookies, all CRUD routes, - cold start, readiness, direct-backend denial, CORS denial and cleanup. -3. The evidence date, region, currency, workload and command output are - recorded. \ No newline at end of file diff --git a/docs/maintainers/operations.md b/docs/maintainers/operations.md index 6d554a3..776ab1b 100644 --- a/docs/maintainers/operations.md +++ b/docs/maintainers/operations.md @@ -1,12 +1,14 @@ # Operating and releasing authenticated applications +> **Compatibility runbook:** This describes SQL Apps' custom Azure deployment for existing environments. Use maintained Azure deployment and diagnostics skills for new projects; retain the SQL/DAB-specific acceptance requirements when migrating. + This checklist is for maintainers and operators of the full authenticated Azure deployment, not the local getting-started path. ## Diagnostics -Container Apps sends service logs to Log Analytics. Functions is configured with Application Insights. SQL server auditing is enabled and delivered to the same Log Analytics workspace through a diagnostic setting, so `SQLSecurityAuditEvents` is queryable with `AzureDiagnostics`. Gateway logs redact authorization/cookies and report request IDs and error types, not raw downstream exception messages. +Container Apps sends service logs to Log Analytics. SQL server auditing is enabled and delivered to the same Log Analytics workspace through a diagnostic setting, so `SQLSecurityAuditEvents` is queryable with `AzureDiagnostics`. Gateway logs redact authorization/cookies and report request IDs and error types, not raw downstream exception messages. -Use Azure CLI for container revisions, logs, identities, role assignments, and resource status. `status` checks the gateway resource and data-service readiness and reports the configured SQL backup retention, backup redundancy and audit workspace; it is not an authenticated storage/function smoke test and it does not verify that a restore or an audit query actually works. +Use Azure CLI for container revisions, logs, identities, role assignments, and resource status. `status` checks the gateway resource and data-service readiness and reports the configured SQL backup retention, backup redundancy and audit workspace; it is not an authenticated smoke test and it does not verify that a restore or an audit query actually works. ## Failure and retry @@ -15,27 +17,30 @@ Use Azure CLI for container revisions, logs, identities, role assignments, and r - Infrastructure/schema/runtime stages are persisted locally. Retry repeats declarative stages rather than assuming prior state is current. - Schema failure prevents new gateway/data rollout; existing runtime resources are not intentionally deleted. - Local locks atomically publish a directory containing a unique process/host owner record. Recovery removes only the observed dead owner's record, so concurrent recovery cannot delete a replacement lock. Legacy incomplete files or empty consent locks are recovered after 30 seconds; live owners remain protected regardless of age. -- `deploy-all` checks the saved deployment fingerprint before skipping completed stages. A changed configuration or role-based SQL/DAB source stops continuation for operator review; a historical ready stage is not proof that a changed release was deployed. -- Gateway proxies use Fastify's native Web-stream handling for incremental delivery and backpressure. Disconnecting a client cancels the upstream response even while its next chunk is pending; loopback HTTP regressions cover both DAB and Functions proxy paths. +- `deploy-all` checks the saved deployment fingerprint before skipping completed stages. A changed configuration or SQL/DAB source makes `artifacts` and `deploy` pending again under a new approval id; a historical ready stage is not proof that a changed release was deployed. +- `deploy` refuses images whose recorded `imageSource` differs from the current SQL/DAB source; run `artifacts`, then `deploy`. A changed configuration or source rolls every stage forward; an unchanged one resumes from its saved stage. +- `deploy-all` stops after ordinary infrastructure provisioning for an administrator-run `rbac-plan`/`rbac-deploy` handoff. Runtime deployment reads every required assignment back from its exact Azure scope before schema or service changes. +- Gateway proxies use Fastify's native Web-stream handling for incremental delivery and backpressure. Disconnecting a client cancels the upstream response even while its next chunk is pending; loopback HTTP regressions cover the DAB proxy path. - ARM changes, SQL publishing, and Graph assignments are not a single transaction. Inspect recorded stages and Azure state after failure. - Runtime rollback does not reverse schema changes. Use expand/contract changes and operator-reviewed recovery. - Do not run deployment from two unrelated machines simultaneously. CI provides concurrency grouping; a distributed lock service is not implemented. -## Durable data - -SQL owns application data and row-level security. Blob owns user files. Files are addressed by tenant/object-ID/name; deleting the application does not require deleting its data. The gateway returns at most 100 files per storage response with an opaque continuation token. The browser loads one page initially and follows a token only when the user selects **Load more files**. +### Live Azure deployment lessons -An Azure Blob SDK regression uses a loopback HTTP fixture to verify the 100-result page limit, one request per page, opaque continuation and private snapshot-prefix exclusion. It needs no live storage account and is not live Azure acceptance. +- A newly created ACR can exist in ARM before its login-server DNS record is consistently resolvable. If preflight reports `CONNECTIVITY_DNS_ERROR`, verify the configured login server with `az acr show`, run `az acr check-health`, and test DNS/HTTPS. Retry after DNS is healthy; do not create a replacement registry or republish images merely because DNS has not propagated. +- ARM deployments are not transactions. A failed `provision` can leave SQL, Container Apps, identities, private endpoints, and monitoring resources running and billing even though no local infrastructure stage was saved. Use `az deployment operation group list` and `az resource list` to inventory the partial result before retrying or making cleanup decisions. +- Container Apps managed-environment creation can fail with `ManagedEnvironmentCapacityHeavyUsageError` / `AKSCapacityHeavyUsage` while other resources succeed. Stop the publish attempt and inventory the partially billed resources. Do not loop retries or switch regions automatically; select a new region only through a fresh placement and cost review. +- Managed-environment retries can remain in `Running` / `Updating` for tens of minutes. Check the ARM deployment and environment provisioning states rather than starting a concurrent deployment. The CLI process can legitimately remain quiet while ARM is still working. +- Ordinary provisioning no longer creates Azure role assignments. After it succeeds, an authorized administrator runs `rbac-plan` and `rbac-deploy`; the ordinary deployer then verifies exact live principal/role/scope assignments before SQL or runtime publication. +- Query Cost Management after deployment, but expect billing data to lag and the API to throttle with HTTP 429. A zero or unavailable same-day result is not proof that the environment is free. Use the declared SKUs and current Retail Prices API as the pre-deployment floor, then reconcile against actual and forecast cost when data arrives. -The SQL administrator intentionally has privileged schema access. The DAB identity receives only table CRUD, and SQL block/filter predicates protect ownership. Gateway and function identities are separate. - -Blob shared keys and anonymous access are disabled. File storage uses managed identity. The Functions host receives Blob/Queue/Table roles on the account; gateway permission is scoped to the file container. +## Durable data -Key Vault is provisioned with private access, RBAC, soft delete, and purge protection. `secret-set` writes a value supplied through `SQL_APPS_SECRET_VALUE` without persisting it locally or printing it. The operator needs Key Vault data-plane write permissions and private connectivity; provisioning permissions alone do not grant access. +SQL owns application data and any row-level security the application defines. Deleting the application does not require deleting its data. -The function identity has Key Vault Secrets User. Function implementations can import `functionSecrets` from `functions/src/secrets.ts` using `KEY_VAULT_URL` and `FUNCTIONS_IDENTITY_CLIENT_ID`. The example echo function does not read or return secrets. Never expose this helper through a generic client-controlled secret lookup endpoint. +The SQL administrator intentionally has privileged schema access. The DAB managed identity receives only `EXECUTE` and object-scoped `VIEW DEFINITION` on the authorized DAB stored procedures; `npm run azure -- grants ` writes that exact set to `dist/sql/role-based-grants.sql` for a maintained deployment route. Gateway and DAB identities are separate; their only Azure role assignments are ACR pull. -Secret values, like SqlPackage access tokens, pass through the tools' normal process arguments on a trusted deployment host. Do not run deployment/secret operations on an untrusted shared host. +SqlPackage authenticates with Microsoft Entra (Active Directory Default through the Azure CLI credential), so no SQL access token is passed on the command line. Still, do not run deployment operations on an untrusted shared host. SQL Apps provisions no Storage or Key Vault; add file storage or secrets with the maintained Azure skills. ## Production release gates @@ -45,10 +50,10 @@ Before production: - Verify network isolation and DNS from both trusted and untrusted hosts. - Pin every deployed image to an immutable digest, including validated base image provenance. - Exercise multiple replicas, temporary SQL outage, and dependency recovery. -- Perform and document a SQL restore drill and Blob retention recovery. -- Set SQL backup retention according to policy; do not treat default retention as an approved recovery plan. The template provisions an explicit `sqlBackupRetentionDays` (default 7) and `sqlBackupRedundancy` (default Geo), and `npm run azure -- status` reports both: confirm the reported values match the approved recovery objective. +- Perform and document a SQL restore drill. +- Set SQL backup retention according to policy; do not treat default retention as an approved recovery plan. The template provisions a 7-day retention window and reports the effective redundancy (default Local). - Confirm SQL auditing reaches the workspace reported by `status`: run a failed login and an authorized query, then query `AzureDiagnostics` for `SQLSecurityAuditEvents` and record the evidence. Provisioned auditing is not verified auditing. -- Burst-test the gateway before launch: drive concurrent authenticated traffic plus unauthenticated requests at `/api/*`, `/graphql` and `/storage`, and record observed throughput, error rates, SQL DTU saturation on S0 and the replica scaling response. The gateway applies no application-level rate limit, so establish the real saturation point and the fronting control (Front Door, App Gateway WAF or API Management) that will absorb it. +- Burst-test before production launch: drive concurrent authenticated traffic plus unauthenticated requests at `/api/*` and `/graphql`, and record observed throughput, error rates, serverless SQL saturation and the replica scaling response. Do not add production-scale controls before measured need. - Configure alert rules and cost budgets for the selected environment. - Verify tenant consent restrictions and least-privilege directory/resource deployment permissions. - Exercise CI SQL authentication/private networking; hosted GitHub runners do not inherently reach private endpoints. diff --git a/docs/reference/copilot-plugin.md b/docs/reference/copilot-plugin.md index e763f4d..a7bf5ca 100644 --- a/docs/reference/copilot-plugin.md +++ b/docs/reference/copilot-plugin.md @@ -21,11 +21,11 @@ The installer: Rerunning the installer is safe for the same checkout/profile. It refuses to replace a conflicting marketplace, differently sourced plugin, or runtime binding. Unrelated existing plugins are not disabled or modified. -Only [plugins/sql-apps](../../plugins/sql-apps) is the installed package: manifest, six skills, four design references, an MIT license and a Node launcher. Runtime code, `.sql-apps` credentials, dependencies and SQL/Blob volumes stay outside the package. **Use this local marketplace, not a direct repository-root installation**, which is the older compatibility package. +Only [plugins/sql-apps](../../plugins/sql-apps) is the installed package: manifest, six focused skills, an MIT license and a Node launcher. Runtime code, `.sql-apps` credentials, dependencies and SQL volumes stay outside the package. **Use this local marketplace, not a direct repository-root installation**, which is the older compatibility package. ## Use the GitHub Copilot App -Restart the App or create a **fresh session** after installation. In **Customize -> Plugins**, look for **sql-apps**, marketplace **sql-apps-skills**, version **0.1.2**, enabled. Its skills should appear under **Skills**: +Restart the App or create a **fresh session** after installation. In **Customize -> Plugins**, look for **sql-apps**, marketplace **sql-apps-skills**, version **0.2.0**, enabled. Its skills should appear under **Skills**: | Skill | Example prompt | | --- | --- | @@ -36,16 +36,10 @@ Restart the App or create a **fresh session** after installation. In **Customize | `sql-apps-application` | Build a domain-specific application without copying the Todo example; verify source, build, API and database boundaries. | | `sql-apps-design` | Agree on a custom app's visual direction and review its rendered desktop/mobile experience. | -The [design skill](../../plugins/sql-apps/skills/sql-apps-design/SKILL.md) includes a -[craft floor](../../plugins/sql-apps/skills/sql-apps-design/references/craft-floor.md), -[UI style recipes](../../plugins/sql-apps/skills/sql-apps-design/references/ui-style-recipes.md), -[visual style recipes](../../plugins/sql-apps/skills/sql-apps-design/references/visual-style-recipes.md) -and [visual formatting rules](../../plugins/sql-apps/skills/sql-apps-design/references/formatting.md). -Its SQL Apps scope and compatibility section takes precedence over stack-specific examples: -use the application's actual styling system, tokens and dependencies rather than assuming -the named frontend files, hooks or packages are supplied by SQL Apps. New packages or -font/image downloads still need explicit approval. SQL, identity and runtime decisions -remain in `sql-apps-application`. +The `sql-apps-design` skill is a small routing adapter for an available frontend design +capability. It retains SQL Apps requirements for real account/access states and truthful +loading, error, save, and reload behavior; SQL, identity, and runtime decisions remain in +`sql-apps-application`. Invoke a skill explicitly from the App's skill picker/slash completion if natural-language discovery picks another plugin instead. Ask for **SQL Apps** and confirm the **sql-apps-skills** marketplace. The plugin does not embed the browser UI; use the startup-reported origin (`http://127.0.0.1:18080/` in fixed-port mode) for the actual application. @@ -61,7 +55,7 @@ For beginners: **"Guide me through SQL Apps setup one step at a time. Ask before For building an app: **"Help me describe, run and change a useful SQL Apps app locally. Ask about its users, information and actions, not cloud architecture. Sharing is optional."** Follow the [build walkthrough](../guides/build-your-app.md). Use `guide` to recover saved decisions and `guide-save ` to save an agreed brief. Historical evidence never replaces live acceptance or authorizes installations/cloud writes. -For repeated local use, see [remembered permissions](local-consent.md). `setup approval --profile

` shows the single local approval (restore, build, workspace, startup, SQL terms) and `setup approve ... --approval ` records it after explicit human approval. The launcher's pre-build `consent-plan|consent-status [selection]`, `consent-grant|consent-grant-once `, `consent-terms `, `consent-revoke [scope|terms]` and `consent-run [selection]` remain for individual scopes and target the selected application checkout. Consult matching grants before asking again. +For repeated local use, see [remembered permissions](local-consent.md). `setup approval --profile role-based-data --workspace ` shows the single local approval (restore, build, workspace, startup, SQL terms) and `setup approve ... --approval ` records it after explicit human approval. The launcher's pre-build `consent-plan|consent-status [selection]`, `consent-grant|consent-grant-once `, `consent-terms `, `consent-revoke [scope|terms]` and `consent-run [selection]` remain for individual scopes and target the selected application checkout. Consult matching grants before asking again. The local marketplace loads from disk (`source: live` in CLI output). Edit the bundled skills or launcher, run `npm run plugin:check`, then start a fresh session/restart to load changes. No Git push or plugin update is needed for this directory-backed source. After TypeScript runtime changes, separately build/restart the application as documented. @@ -83,10 +77,11 @@ For a connected first-run or return journey, the same launcher exposes `setup plan|status|run|resume` before dependency restore or build. For example: ```powershell -node "C:\git\sql-apps\plugins\sql-apps\scripts\sql-apps.mjs" setup plan --profile role-based-data --json +node "C:\git\sql-apps\plugins\sql-apps\scripts\sql-apps.mjs" setup plan --json ``` -Select the profile from agreed app scope. See [Setup coordinator](setup.md) for +`role-based-data` is the only profile, so `--profile role-based-data` is +optional. See [Setup coordinator](setup.md) for workspace selection, approvals, installation plans and continuation. `setup` forwards arguments literally to the bound checkout's coordinator and retains source/binding checks. It does not change loaded skills, grant permissions, @@ -129,7 +124,7 @@ node "C:\git\sql-apps\plugins\sql-apps\scripts\sql-apps.mjs" setup-check `guide` is also dependency-free and available before the runtime is built. It returns JSON with the four stages, saved brief, suggested next action and explicitly historical progress; it does not probe live services. `guide-save ""` validates and saves the agreed brief in `.sql-apps/guide.json` in the bound checkout. Both enforce the same runtime-home mismatch checks. Source changes flag saved progress for local re-verification. See [the brief contract](guide.md); do not include credentials or actual data. -The guide also introduces cost awareness progressively; optional saved `costPreference` is not spending approval. The cloud-preview skill selects the approved application profile first. Offline `demo-cost` is synthetic-demo-only; `role-based-cost` matches the [role-based-data profile](role-based-data.md), whose `role-based-app`/`role-based-serve` commands are also exposed by the launcher. A zero-spend intent stays blocked while the minimal template has registry/network fixed charges; budgets are alerts, not spending caps. See [costs and paid growth](../guides/costs.md). +The guide also introduces cost awareness progressively; optional saved `costPreference` is not spending approval. The cloud-preview skill confirms the `role-based-data` application first. Azure estimates and spend analysis route to the Azure Cost plugin. The launcher exposes the [role-based-data](role-based-data.md) `app`/`serve` commands and their `role-based-app`/`role-based-serve` aliases. Budgets are alerts, not spending caps. See [costs and paid growth](../guides/costs.md). If the active checkout differs from the installed binding, runtime execution is refused until you explicitly select the intended home. This also applies when the active worktree lacks the uncommitted foundation. Review the report, then select one checkout for this terminal/session: @@ -140,7 +135,7 @@ node "C:\git\sql-apps\plugins\sql-apps\scripts\sql-apps.mjs" workspace-check This does not change the global binding, transfer source, or authorize copying files between worktrees. `home` and `workspace-check` remain available to diagnose mismatches. Runtime commands also require matching build provenance: `npm run build` records the source and compiled-artifact fingerprints and refuses to certify a build if source changes during compilation. Missing, stale, copied or tampered artifacts require a rebuild in the selected checkout. Source/build provenance is not live service ownership or cloud acceptance. -`status` probes only loopback gateway liveness/data readiness and DAB HTTP health, with bounded timeouts and a nonzero exit on failure. It is read-only and does not create a development session or expose credentials. It does not certify worker/storage/SQL authorization. The launcher delegates local commands directly to the existing compiled CLI with the checkout as cwd, without shell interpolation; it never installs a second runtime. +`status` probes only loopback gateway liveness/data readiness and DAB HTTP health, with bounded timeouts and a nonzero exit on failure. It is read-only and does not create a development session or expose credentials. It does not certify SQL authorization or the application's required role. The launcher delegates local commands directly to the existing compiled CLI with the checkout as cwd, without shell interpolation; it never installs a second runtime. `setup-check` instead runs the dependency-free diagnostic in the bound checkout and prints JSON before runtime compilation. An optional second argument selects an existing SQL container; it does not initialize it. Action-needed reports exit nonzero, including a healthy gateway whose ownership must still be confirmed. Do not mistake that for permission to restart it. It reports selected workspace mode/ports/origins and requires matching workspace labels for isolated occupied Docker ports. For a new checkout, use `workspace-plan` after building, review its proposal, and approve `workspace-init `; use explicit `workspace-init legacy` to preserve an existing stack. The plugin passes omitted SQL arguments through to the workspace's derived default, not the global legacy container. @@ -162,8 +157,8 @@ The `app` command remains running until stopped. If healthy services are already - This plugin adds skills, not MCP tools, custom UI, or another authentication layer. - No automatic startup hooks or cloud deployment commands are added. - Agents still use normal host approval/task controls; installing a plugin does not grant blanket tool permissions. -- Retrying jobs and running maintenance mutate data; maintenance can delete expired data if retention is explicitly enabled. -- `services-test` briefly stops/restarts owned worker/storage. Do not run it while another user is processing. +- Writing records through the app or running SQL acceptance against shared data mutates or reads that data; confirm the target workspace first. +- `app-test` starts its own approved stack and checks that Development Alice is admitted and Development Bob receives 403. Do not run it against a workspace another user is relying on. - Preserve owner checks, production authentication and unrelated/user-owned containers. - Cloud preparation requires separate authorization for mutations. The existing Azure template contains billed resources. @@ -174,7 +169,7 @@ copilot plugin uninstall sql-apps@sql-apps-skills copilot plugin marketplace remove sql-apps-skills ``` -These operations remove only this plugin/marketplace registration, not the application checkout or SQL/Blob data. The small `sql-apps-local.json` runtime binding remains for future reinstalls; remove that exact file manually if no longer wanted. Do not remove the Copilot profile directory or unrelated plugins. +These operations remove only this plugin/marketplace registration, not the application checkout or SQL data. The small `sql-apps-local.json` runtime binding remains for future reinstalls; remove that exact file manually if no longer wanted. Do not remove the Copilot profile directory or unrelated plugins. ## Replace a pre-rebrand installation diff --git a/docs/reference/demo-cost.md b/docs/reference/demo-cost.md deleted file mode 100644 index 8d632dc..0000000 --- a/docs/reference/demo-cost.md +++ /dev/null @@ -1,135 +0,0 @@ -# Demo cost review and SQL billing reference - -For an overview before deciding whether to share your app, read [Costs and growth](../guides/costs.md). This reference documents the offline command, configuration and template settings. - -**Free tier** means recurring monthly allowances, not trial credits or a guaranteed free deployment. The Azure SQL documentation uses "free offer" for its enrollment mechanism. Eligibility, usage limits and separate paid resources still apply. - -For a true zero-dollars or absolute-lowest-cost proof-of-concept, the repo does not accept the claim that "App Service free tier exists" or "Azure SQL has a free offer" as proof that the whole app is free. The real question is whether the chosen hosting plan, SQL mode, registry, networking and observability still leave fixed charges or a paused database at the end of the month. A minimal local or single-tenant proof-of-concept remains the safest default when the goal is zero spend. - -## Offline demo cost review - -After building, run: - -```powershell -npm run azure -- demo-cost azure-demo-cost.example.json -``` - -This reads the [example contract](../../azure-demo-cost.example.json) and produces JSON without login, Azure requests, resource writes or changing subscription defaults. The example deliberately requests zero Azure spending. - -**The current template cannot satisfy that request:** it creates ACR Basic and custom-network Container Apps managed infrastructure. Their charges do not disappear at zero app replicas. The report says `blocked-by-fixed-charges` and exits **2**, rather than producing a success-shaped free-deployment result. - -- Exit **0**: the local choices permit a separate target/pricing/what-if review. Not approval to deploy. -- Exit **2**: zero-spend is blocked or fixed-charge acknowledgement is missing. -- Exit **1**: invalid input or command failure. - -The command is an offline review, not an enforced Azure spending cap. It does not intercept arbitrary Bicep/CLI deployments or certify eligibility. The candidate guided publishing interface is documented in [public-demo deployment](demo-deployment.md); it remains gated and is not the default. - -Do not remove narrow SQL access, enable a broad Azure-services rule, publish private source/images or change registries to claim zero cost. An alternative secure free-hosting/image-distribution design needs separate review. Stay local if zero cloud spend is mandatory and no verified design meets it. - -### Separately reviewing paid supporting resources - -If you choose to review paid supporting resources, use a local `azure-demo-cost.json`: - -```json -{ - "version": 1, - "profile": "public-demo", - "hosting": "static-web-apps", - "intent": "review-paid-costs", - "sql": { "mode": "free-paused" }, - "acknowledgeFixedCharges": true -} -``` - -The acknowledgement concerns known fixed charges, not deployment permission or an agreed monthly price. Actual prices depend on region, currency, account/offer and usage. The report deliberately returns `estimatedMonthlyCost: null` until a real pricing review occurs; it does not invent dollar values. - -For a separately chosen ordinary paid database: - -```json -"sql": { - "mode": "paid-reviewed", - "sku": { "name": "S0", "tier": "Standard", "capacity": 10 }, - "maxSizeBytes": 268435456000, - "backupRedundancy": "Geo" -} -``` - -Replace the SQL object in the complete paid-review configuration. This illustrates the contract, not a recommended sizing or price quote. The SKU must be supported for the selected region/subscription and reviewed for the actual workload. Unknown fields, paid SQL under zero-spend intent, and free-tier paid-continuation choices are rejected before cloud access. - -The normal local configuration name is ignored by Git and excluded from Docker contexts. Keep secrets and actual application records out of cost configuration. - -## New SQL database billing policy - -The minimal [foundation template](../../infra/demo.bicep) requires an explicit `sqlBillingMode`: - -- **`free-paused`:** General Purpose serverless `GP_S_Gen5_2`, maximum 2 vCores, minimum 0.5 vCore, 32 GiB maximum data, local backup redundancy and a 60-minute idle auto-pause delay. Sets `useFreeLimit: true` and `freeLimitExhaustionBehavior: AutoPause`. -- **`paid-reviewed`:** an ordinary paid database using the explicitly supplied SKU, maximum size and backup redundancy. It does not enable free-tier paid continuation. - -Both modes retain required sizing parameters for the template contract. In free-paused mode the fixed free settings above take precedence; use the matching `sql.parameters` fragment returned by `demo-cost`. In paid-reviewed mode the supplied sizing is used. The cost fragment is not a full deployment parameter file or a supported manual substitute for the unfinished deployment guide. - -If free enrollment fails because of eligibility, region, available slots or policy, stop and explain the failure. Never retry by disabling the free limit, changing the billing mode or selecting another region automatically. - -This policy is for **new databases**. Do not apply it as an existing-database conversion or interpret a cost acknowledgement as approval for an irreversible change. In particular, Microsoft documents that free-tier paid continuation cannot revert to pause-at-monthly-limit behavior. - -Under the free tier, exhaustion can make the database unavailable until the next calendar month. Idle auto-pause and resume also affect responsiveness. Open connections can prevent idle auto-pause; pooling, readiness probes and polling need live observation before promising that a deployed SQL/DAB app sleeps. - -## Allowances and what consumes them - -Current documentation reviewed on **2026-10-09**: - -| Resource | Included amount | Scope and caveat | -| --- | --- | --- | -| SQL free tier | 100,000 vCore-seconds, 32 GB data and 32 GB backup storage | Per database/month, up to 10 databases per subscription; verify subscription/region eligibility. | -| Container Apps Consumption | 180,000 vCPU-seconds, 360,000 GiB-seconds, 2 million HTTP requests | Shared across the subscription per calendar month; overages are billable. | -| DAB | No separate DAB service fee | Its container still consumes allocated CPU/memory. | -| ACR Basic | Paid registry tier | Recurring charge plus applicable additional usage; not stopped by app scale-to-zero. | -| Custom-network managed infrastructure | Applicable public-IP/load-balancer charges | Inspect the managed infrastructure resource group, not just the app resource group. | - -The [minimal runtime](../../infra/demo-runtime.bicep) allocates 0.75 vCPU and 1.5 GiB across gateway and DAB. With all grants unused, the CPU and memory grants each cover about **66.7 active replica-hours**. This is illustrative allocation time, not visitor-hours, a price quote or a spending cap. Startup, gaps between visits, bots and other apps can consume allowances. Request allowance alone does not establish free operation. - -SQL's 100,000 vCore-seconds equal about **27.8 vCore-hours**, not a user/query allowance. Maximum configured vCores do not mean that allocation is always consumed at that maximum; observe actual billing/usage. - -Optional file storage, background jobs, observability, data transfer, build operations, AI and retention can add charges. Review the actual chosen capabilities, account-wide grant usage and full resource inventory. - -## Visibility before growth - -Neither `demo-cost`, `demo-preflight` nor the saved guide checkpoint queries actual costs/usage or configures alerts. Their reports state that explicitly. - -Before cloud writes, review region/currency/account pricing and available grants. After separately approved deployment: - -Use [Monitor usage and decide when to grow](monitor-growth.md) for an end-to-end workflow with the free Database Hub preview, SQL resource queries, and before/after verification. Monitoring requires separate opt-in setup; these SQL Apps commands do not enable it. - -1. Check SQL's **Free monthly vCore amount** / **Free amount remaining** in the portal. The free-tier guidance describes a remaining-compute alert before exhaustion. -2. Review Container Apps consumption across the subscription. Avoid claiming this app has the whole shared grant. -3. Inspect actual and forecast costs in both the app resource group and the Container Apps managed infrastructure resource group. -4. Configure reviewed budget/usage alerts with explicit recipients and thresholds. **Budgets are delayed notifications, not hard spending caps; they do not stop resources.** -5. Keep resource ownership and a reviewed cleanup/export path. Stopping the app is not deleting a paid registry, database or networking resources; never delete user data automatically. - -Choose paid growth when measured usage or requirements justify it: allowance exhaustion, unacceptable cold starts, people relying on availability, capacity/latency pressure, or stronger real-data recovery/access requirements. The learning profile may be inappropriate even for a small number of users. - -Public visitor-session creation has no app-level admission throttle or rate limit: anyone may create a session at any rate. The application's SQL does enforce a **global live-session cap of 1000**; past it, `CreateSession` returns `capacity` and the gateway answers HTTP 429 until sessions expire (60 minutes) or are deleted. Because creation is unthrottled, roughly a thousand rapid requests can exhaust that cap and make the demo refuse new visitors for up to an hour. That is a deliberate availability-over-cost trade: the cap bounds database work rather than silently buying capacity. Popularity should prompt usage review and an explicitly approved move to paid capacity, not an automatic billing change. Per-session data/mutation limits still apply. Automated traffic can consume allowances too; this policy is not a spending cap or an availability guarantee. Free-tier SQL still pauses when its configured allowance is exhausted. These commands do not implement usage monitoring or automatically upgrade the application. - -The demo environment ships Container Apps console logs to its own Log Analytics workspace so a failed visitor session can actually be diagnosed. The workspace is capped at 0.1 GB per day with 30 day retention, inside the included free allowance; ingestion above the cap is dropped rather than billed. Raising `logRetentionDays` or the daily cap is a billable change and needs its own cost review. - -The zero-to-one replica limit bounds app capacity, not total cost. An authenticated real-data application needs its separate [production deployment review](deployment.md); never expose real sensitive data anonymously to achieve a cheaper demo. - -## Compare the hosting candidates - -The Static Web Apps option adds a Standard plan for static delivery and keeps one linked Container App for the Node API and internal DAB. It does not replace ACR, networking, SQL or Container Apps, so it is likely not cheaper than the current Container Apps default. - -After recording current regional/account prices and measured workload usage in a local comparison configuration, run: - -```powershell -npm run azure -- hosting-compare azure-demo-hosting-comparison.json -``` - -Compare both options using the same evidence date, region, currency, requests, transfer and active-backend hours. Unknown prices remain unknown rather than zero. `container-apps` remains recommended when the Static Web Apps total is higher or live acceptance is false. `static-web-apps` can be recommended only when its measured total is no greater and live acceptance is true. The report does not deploy resources or change the default; record and review the evidence separately. - -## Sources to recheck before deployment - -- [Azure SQL free tier (Microsoft's free-offer documentation)](https://learn.microsoft.com/azure/azure-sql/database/free-offer) -- [Container Apps billing](https://learn.microsoft.com/azure/container-apps/billing) -- [Container Apps pricing](https://azure.microsoft.com/pricing/details/container-apps/) -- [Container Registry pricing](https://azure.microsoft.com/pricing/details/container-registry/) -- [Custom-network managed infrastructure](https://learn.microsoft.com/azure/container-apps/custom-virtual-networks#managed-resources) -- [Cost Management budgets](https://learn.microsoft.com/azure/cost-management-billing/costs/tutorial-acm-create-budgets) diff --git a/docs/reference/demo-deployment.md b/docs/reference/demo-deployment.md deleted file mode 100644 index ecb0828..0000000 --- a/docs/reference/demo-deployment.md +++ /dev/null @@ -1,128 +0,0 @@ -# Public-demo publishing candidate - -Use this reference to verify a selected local reference app, review its Azure target and understand the candidate publishing journey. This path is only for an anonymous public demo with synthetic data. Authenticated role-based-data and full-foundation applications keep their existing private guidance and deployment references. - -## What the candidate deploys - -The visitor sees one app at **one public URL**. Azure Static Web Apps Standard serves only the selected static website and forwards same-origin `/api/*` requests to one linked Azure Container App. The Container App runs the protected Node API and a self-hosted DAB sidecar. DAB is internal, has no public ingress and connects to Azure SQL using managed identity. - -This is not an App Service website. It does not use the retired Static Web Apps Database Connections feature or `/data-api`. Static Web Apps links only the Node API; the browser never calls DAB or the Container App hostname directly. - -Static Web Apps Standard can charge, as can ACR, networking, SQL and Container Apps. Adding Standard is likely not cheaper than the current Container Apps design. Container Apps therefore remains the default until `hosting-compare` has current, same-basis cost evidence and every live acceptance gate below passes. - -## Candidate command journey - -The proposed end-to-end command sequence is: - -```powershell -npm run app:build -- todo public-demo -npm run azure -- demo-cost azure-demo-cost.json -npm run azure -- demo-preflight azure-demo-target.json -npm run azure -- demo-publish azure-demo-publication.json -npm run azure -- demo-publish azure-demo-publication.json --approve -npm run azure -- demo-status azure-demo-publication.json -npm run azure -- hosting-compare azure-demo-hosting-comparison.json -``` - -Treat this as a candidate interface until its implementation and live gates are complete. `demo-publish` without `--approve` is review mode: it must be read-only and print the exact pending writes, known charges, exclusions, source/image publication, cleanup plan and approval ID. The ID binds approval to that exact pending work. Do not approve if the target, configuration or inventory changes. - -The publication configuration keeps the Container Apps `location` separate from `staticWebAppLocation`, because Static Web Apps has a smaller region set. It requires `cost.intent: review-paid-costs` and `cost.acknowledgeFixedCharges: true`; the fixed-charge acknowledgement and SQL choices are part of the approval hash. This does not replace the dated regional/account pricing review or authorize a default-hosting change. - -The configuration also requires the reviewed public IPv4 address of the schema runner. The schema stage creates a firewall rule for only that address, publishes with Microsoft Entra authentication, and removes the rule before continuing. A cleanup failure stops publication; it never substitutes a broad Azure-services rule. - -Static upload obtains the deployment token from the explicitly reviewed subscription, resource group and Static Web App, keeps it only in the child process environment, clears inherited Azure service-principal variables, and redacts the token from errors. The workflow verifies the deployed `app.js` hash before saving the static stage, so a misleading zero exit from the SWA CLI cannot make resume skip an upload that did not happen. - -After explicit approval, the approved form performs only the reviewed stages. A failed stage must stop, preserve deployment state and print the exact resume command; resuming must not delete data or repeat completed work. `demo-status` reconciles owned resources and stage state without claiming that a reachable URL passed acceptance. Neither status nor cost review authorizes additional writes. - -`hosting-compare` uses dated pricing and measured usage on the same region, currency and workload basis. It can recommend the Static Web Apps candidate only when its measured monthly total is no greater and live acceptance is true. It never changes the default automatically. - -## Choose the profile first - -- **Local simulation:** subscription-free, loopback-only anonymous synthetic sessions for this selected reference. This is separate from the default foundation's local preview account journey, which keeps identity testing on a separate developer-only page. -- **Public synthetic-data demo:** intended anonymous HTTPS browser/API, internal DAB and Azure SQL. It must require synthetic-data acknowledgement and omit visitor sign-in, app-registration creation and Graph prerequisites. Cloud resources are separately authorized and can incur charges. -- **Authenticated application:** use the existing [full-foundation deployment](deployment.md), with its explicit Entra and service requirements. It is not the minimal public-demo path. - -Never infer an authenticated production profile from the word "deploy." - -`architecture-plan` is an offline, metadata-only review aid; see [adaptive architecture planning](deployment.md#adaptive-architecture-planning). It does not convert an existing database or legacy paid deployment and is not deployment approval. Keep using this candidate journey and its live gates until the compiler reaches deployment parity and live acceptance. - -## Free-tier-first cost review - -Start with [costs and paid growth](../guides/costs.md), then run `npm run azure -- demo-cost azure-demo-cost.example.json` after building. This is offline and makes no Azure requests. The zero-spend example exits 2 with `blocked-by-fixed-charges`: SQL/compute free allowances do not remove ACR Basic or custom-network managed infrastructure charges. See [the cost command reference](demo-cost.md) for configuration and output details. - -The review reports known charge sources, SQL policy, shared grants, theoretical compute headroom and usage-monitoring next actions; it does not invent prices, query actual usage or configure budgets. A paid-cost review requires an explicit fixed-charge acknowledgement, but never authorizes deployment. Do not silently change hosting/network security or use paid resources when a free-only intent is blocked. - -Public visitor-session creation is not admission-throttled or rate-limited, but the application's SQL enforces a global cap of 1000 live sessions; beyond it the gateway returns HTTP 429 until sessions expire after 60 minutes. Unthrottled creation therefore makes a denial of new visitors reachable with about a thousand rapid requests. Follow the [usage and paid-growth policy](demo-cost.md#visibility-before-growth): popularity prompts owner-approved capacity review, not automatic billing changes. Per-session limits and SQL free-tier exhaustion pause still apply. - -## Verify the actual selected app locally - -Follow [the reference guide](../../examples/todo/README.md) and [local development](local-development.md). Select the runtime home/workspace explicitly, build the selected artifact, run `selected-app`, open its reported browser origin, and approve `selected-test` separately. The default application remains sample-free. - -Local SQL/DAB/browser acceptance does not verify Azure managed-identity authentication, Azure policy, quotas, public HTTPS ingress or deployment readiness. - -## Read-only tenant-scoped Azure diagnostics - -This step requires an Azure subscription and a CLI user or service-principal sign-in. It is unnecessary for local development. Confirm the exact target and approve these read-only Azure requests first; do not log in, refresh caches or change global subscription selection automatically. - -Copy [the diagnostic target example](../../azure-demo-target.example.json) to a local configuration and replace every placeholder with the chosen tenant, subscription, resource group, backend region and Static Web Apps region. This is a separate strict diagnostic contract, not the full-foundation configuration. No app-registration IDs, service images or secrets belong in it. - -```powershell -npm run build -npm run azure -- demo-preflight "" -``` - -The command distinguishes: - -1. **CLI subscription cache:** missing, stale or unavailable discovery is reported independently. A default-tenant discovery/Graph error is not proof of revoked ARM access. -2. **Tenant token:** the existing CLI acquires an ARM token for the explicitly chosen tenant. The supported sign-in methods for `--tenant` are user/service principal, not Cloud Shell or managed-identity accounts. The command uses UTC `expires_on` metadata (CLI 2.54.0 or later) instead of ambiguous local-time expiration. -3. **Actual ARM access:** in-memory, bounded HTTPS reads target the selected subscription/group using that token. Nothing changes the CLI's global defaults. Tokens are never printed or persisted; pagination cannot leave the exact ARM endpoint/scope. -4. **Providers and region:** required minimal-service providers must already be registered and advertise the chosen region. Registration, alternate-region selection and resource-group creation are not automatic repairs. -5. **Candidate write authorization:** effective permission pages are inspected, including action exclusions. Missing grants, policy/deny responses, forbidden ARM access and transient failures have distinct diagnostic codes. -6. **Policy/quotas:** an eligible diagnostic result still explicitly reports policy as not yet evaluated. Approved target-specific what-if/deployment must evaluate conditional access, deny assignments, policy, quotas and the chosen SQL configuration. Read access is not deployment proof. - -This command makes no Graph requests, registrations, image publication, schema changes, resource writes or firewall changes. A missing resource group is reported as requiring separately approved creation, not silently created. Provider registration failures need deliberate target review rather than a generic login reset. - -The existing full-foundation commands remain separate. Before future demo provisioning, review all selected billable dependencies, explicit SQL settings, network changes, source publication and a precise teardown path. No cloud write is authorized by saving this diagnostic configuration. - -## Minimal template preparation - -The foundation's default deployment prefix is `sqlapps`. The runtime requires an explicit `applicationName`, which determines the browser hostname prefix; use a reviewed `sqlapps`-prefixed name for the starter. Existing resource names and URLs are not changed automatically. - -The separate [foundation template](../../infra/demo.bicep) prepares a Container Apps Consumption environment, one runtime managed identity, Basic Azure Container Registry, a selected VNet/subnet with a SQL service endpoint, Azure SQL server/database and narrow subnet rule. The [runtime template](../../infra/demo-runtime.bicep) subsequently deploys only the selected gateway and internal DAB sidecar. This split allows infrastructure, source publication and SQL preparation to remain separately approved stages. - -- **SQL billing is explicit:** require `sqlBillingMode` with `free-paused` or `paid-reviewed`. Free-paused enrolls a new serverless database in the free tier with `AutoPause`, fixed 2-vCore maximum/0.5 minimum, 32 GiB size, local backup redundancy and 60-minute idle auto-pause. Paid-reviewed uses the required reviewed SKU/size/backup parameters. Both retain those parameter fields; use the matching cost report fragment. No paid continuation, existing-database conversion or paid fallback is authorized. Verify free eligibility/region/slots; unsupported configurations must fail rather than change tiers. -- **Networking is restricted, not private:** SQL's public endpoint is enabled but accepts the chosen subnet rule; no broad Azure-services exception or runner firewall rule is created by these templates. Any temporary runner rule belongs to the separately approved SQL setup workflow. -- **Runtime is bounded:** HTTPS gateway ingress only, one active revision, zero-to-one replicas and loopback-only DAB. Scale-to-zero is not a claim that SQL, registry, networking or other platform charges disappear. -- **Anonymous work is bounded:** SQL allows at most 1,000 active visitor sessions and 300 mutation attempts per minute across the demo, in addition to each session's 50-item and 30-mutation limits. Reaching a global limit returns HTTP 429. -- **Usage visibility is separate:** review actual subscription-wide compute grants, SQL remaining free amount, and app/managed-group costs after approved deployment. Budgets are alerts, not hard spending caps; these preparation commands configure none. -- **Logging is deliberately minimal:** a capped Log Analytics workspace (0.1 GB/day, 30 day retention) receives Container Apps console logs so failures are diagnosable, and nothing else is provisioned. No Application Insights, storage, Functions, Key Vault, private endpoints or NAT gateway. This small demo profile is not an operational production baseline. -- **Images remain selected:** build outputs contain separate gateway and pinned-base DAB Dockerfiles, OCI source/selection/route labels and an allowlist Docker build context. SQL setup files, the local-path manifest, unrelated examples and tests are not included in that context. Published images still require immutable digests and separate source-publication approval. - -With Azure CLI/Bicep already installed, the following compiles both templates into temporary files, checks the generated resource/SQL/network/ingress contracts and removes the temporary output. It does not require a subscription sign-in or provision resources: - -```powershell -npm run build -npm run infra:test-demo -``` - -These are preparation components, not a supported manual substitute for the candidate command journey. SQL identity/procedure probing, image publication, ownership/resume reconciliation and live HTTPS acceptance must be completed before reporting a successful demo deployment. - -## Required live acceptance - -Run this checklist against the deployed Static Web Apps hostname after separately approved publication. Record evidence for every item. A failed security or isolation check blocks release, keeps Container Apps as the default and leaves Static Web Apps in candidate status. - -- [ ] `GET /` returns the selected static application from the Static Web Apps hostname. -- [ ] `POST /api/session` sets `__Host-sql_apps_session` with `Secure`, `HttpOnly`, `SameSite=Strict` and `Path=/`. -- [ ] `GET`, `POST`, `PATCH` and `DELETE /api/todos` preserve the current status and response contracts. -- [ ] A second browser session cannot read the first session's rows. -- [ ] Expired and deleted sessions cannot read their rows. -- [ ] `/api/application/config` reports the selected public-demo limits. -- [ ] `/api/health/ready` succeeds through Static Web Apps after a cold start. -- [ ] Direct backend calls are denied: the Container App hostname rejects `/api/session` and `/api/todos`. -- [ ] A mutation with a foreign `Origin` is rejected. -- [ ] Requests that submit `X-MS-CLIENT-PRINCIPAL` or `X-MS-API-ROLE` are rejected. -- [ ] DAB has no public ingress, and `/graphql` and `/mcp` remain disabled. -- [ ] SQL has no broad Azure-services firewall rule. -- [ ] Actual costs appear only in the explicitly owned resource groups and match the hosting-comparison inventory. - -References: [CLI token acquisition](https://learn.microsoft.com/cli/azure/account#az-account-get-access-token), [CLI authentication and UTC expiration](https://learn.microsoft.com/cli/azure/authenticate-azure-cli), and [effective resource-group permissions](https://learn.microsoft.com/rest/api/authorization/permissions/list-for-resource-group). diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index 7c4bfa0..c211d40 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -1,34 +1,30 @@ # Authenticated Azure deployment reference -Use this reference when deploying an authenticated application with the full Azure infrastructure template. Start with [sharing your app](../guides/sharing.md) to choose the appropriate path. For an existing pre-rebrand deployment, review [migration notes](../maintainers/naming-transition.md) first. +Use this reference when deploying an authenticated application with the SQL Apps Azure infrastructure template. Start with [sharing your app](../guides/sharing.md) to choose the appropriate path. For an existing pre-rebrand deployment, review [migration notes](../maintainers/naming-transition.md) first. + +> **Existing deployments:** This reference documents SQL Apps' retained Azure deployment for the role-based-data profile. For new projects, use maintained Azure Skills (`azure-app-onboard` when architecture is open, then `azure-prepare` -> `azure-validate` -> `azure-deploy`, with `azure-cost-optimization` and `azure-diagnostics`) and the Azure SQL skills (`build-app-on-azure-sql`, `dab-rest-and-graphql`, `deploy-app-to-azure`, `entra-id-auth`), and retain SQL Apps' application-specific identity, DAB, schema, and acceptance requirements. Do not mix `.azure` deployment state with the custom `npm run azure` state. ## Profile and deployment subject -The default authenticated foundation path includes Functions, storage, Key Vault and private networking. The explicit [role-based-data profile](role-based-data.md) uses the same orchestration with two images, human application roles and private SQL, excluding file/job services. Do not add excluded services or use an anonymous demo without an agreed scope change. `demo-cost` remains demo-only; use `role-based-cost` for role-based-data and review actual regional/account prices separately. +The authenticated cloud path is the [role-based-data profile](role-based-data.md), the only SQL Apps profile: a private Azure SQL Database, Azure Container Registry images for the gateway and Data API builder (DAB), and two scale-to-zero Container Apps, with human application roles and free-limit SQL. There are no Functions, Storage or Key Vault resources. File storage or background jobs belong to maintained Azure skills, outside SQL Apps. Do not use an anonymous demo without an agreed scope change. Review actual regional/account prices with the Azure Cost plugin before approving paid steps. Keep the confirmed new application checkout/configuration/artifacts as the deployment subject. With read-only Azure authorization, discovered older resources are potential collisions, not permission to reuse or modify them. Reuse requires an explicit request and a separate compatibility/migration/cost review; existing data remains untouched. -## Adaptive architecture planning - -`npm run azure -- architecture-plan ` reads a strict architecture intent and produces an offline review plan. It makes no Azure calls or writes. Every plan keeps Azure SQL free-tier intent and DAB as mandatory modules; its output is metadata-only with `authorized: false` and `deployable: false`. The plan makes no pricing claim and does not verify Azure eligibility. - -The compiler does not convert an existing database or a legacy paid deployment, is not deployment approval, and is not ready to deploy its plan. The existing profile guidance and deployment commands below remain authoritative until a later implementation reaches command parity and passes live acceptance. - ## Supported tools The project uses Azure CLI/Bicep for infrastructure and Microsoft.Build.Sql/SqlPackage for schema publishing. No custom Azure control-plane client or SQL migration engine is implemented. Run the validation commands in [Contributing](../../CONTRIBUTING.md) first. Sign into the correct tenant using `az login`; CI uses Azure Login federation. All resource commands specify the configured subscription instead of changing your global default subscription. -The runtime uses stateless bearer tokens. User tokens must be v2 access tokens for the configured API, with the `access_as_user` scope. Functions require an application token with `Function.Invoke` and the exact gateway managed-identity principal. No caller-supplied identity headers are trusted. +The runtime uses stateless bearer tokens. User tokens must be v2 access tokens for the configured API, with the `access_as_user` scope. No caller-supplied identity headers are trusted. -`DAB_URL`, `FUNCTIONS_URL` and `BLOB_ACCOUNT_URL` must be exact HTTPS origins without embedded credentials, paths, queries or fragments. Runtime validation fails before startup rather than forwarding a bearer token to an insecure or ambiguous endpoint. +`DAB_URL` must be an exact HTTPS origin without embedded credentials, paths, queries or fragments. Runtime validation fails before startup rather than forwarding a bearer token to an insecure or ambiguous endpoint. DAB returns relative REST continuation links so clients remain on the public gateway origin. The isolated Todo example tests pagination, preserving opaque tokens and rejecting unsafe links; it is not part of application delivery. Application-specific clients need equivalent pagination validation. Develop and verify the SQL/data layer first using [local development](local-development.md), without provisioning cloud resources. Local claim simulation must never be deployed; cloud DAB continues to validate Entra bearer tokens. -The default cloud DAB identity gets table-scoped read and `VIEW DEFINITION` for owner-filtered FileJob reads, with private snapshot keys excluded. The local processor uses separately gated CRUD; shared SQL filter/block predicates enforce ownership independently of any Todo sample. Cloud job production remains future work. Real applications must define domain-specific entities/grants and acceptance; use update-only PATCH with `If-Match: *` where required rather than implicit upsert. +The cloud DAB managed identity receives only `EXECUTE` and object-scoped `VIEW DEFINITION` on the authorized DAB stored procedures. Real applications must define domain-specific procedures/grants and acceptance; implement row or owner restrictions in SQL where required. ## Resource group and images @@ -39,54 +35,73 @@ az group create --subscription --name --locat az acr create --subscription --resource-group --name --sku Basic --admin-enabled false ``` -All three image references must use an explicit version tag or digest. After filling in the configuration, build from the repository root: +Both image references (`gatewayImage` and `dabImage`) must use an explicit version tag or digest. After filling in the configuration, build from the repository root: ```powershell npm run azure -- artifacts sql-apps.json ``` -`artifacts` invokes ACR builds for all three services using distinct configured version tags, resolves each result to its digest, then atomically updates your configuration only after every build succeeds. If a build fails, earlier images may exist in ACR but configuration remains unchanged. Use new version tags for each build; do not overwrite release tags. This command publishes your source to your configured Azure registry, and requires ACR build/push/read permissions. +`artifacts` invokes ACR builds for the gateway and DAB images, resolves each result to its digest, and records `imageSource`, a fingerprint of the SQL/DAB source the images were built from. It atomically updates your configuration only after both builds succeed. If a build fails, an earlier image may exist in ACR but configuration remains unchanged. Already pinned images are rebuilt under a fresh timestamp tag, so release tags are never overwritten. This command publishes your source to your configured Azure registry, and requires ACR build/push/read permissions. Alternatively use the underlying standard Azure CLI commands: ```powershell -az acr build --subscription --registry --image sql-apps:0.1.2 --file Dockerfile . -az acr build --subscription --registry --image sql-apps-dab:0.1.2 --file dab/Dockerfile . -az acr build --subscription --registry --image sql-apps-functions:0.1.2 --file functions/Dockerfile . +az acr build --subscription --registry --image sql-apps:0.2.0 --file Dockerfile . +az acr build --subscription --registry --image sql-apps-dab:0.2.0 --file dab/Dockerfile . ``` -ACR tasks require suitable Azure permissions and may have region/subscription restrictions. Images are not built by `deploy`. Use immutable digests for release, not tags that are overwritten. Publishing code to a registry is an explicit operator action. +Images built this way have no recorded `imageSource`, so `deploy` refuses them; use `artifacts` for SQL Apps deployments. ACR tasks require suitable Azure permissions and may have region/subscription restrictions. Images are not built by `deploy`. Use immutable digests for release, not tags that are overwritten. Publishing code to a registry is an explicit operator action. A legacy `functionsImage` setting is accepted only as an empty string. ## Shortest path After `az login` as the person who will administer SQL, generate a configuration -read-only; tenant, subscription, SQL administrator and (for `role-based-data`) -role settings are derived, so only placement is asked for: +read-only; tenant, subscription, SQL administrator and role settings are +derived, so only placement is asked for: ```powershell -npm run azure -- init sql-apps.json --profile foundation --location eastus --resource-group rg-sql-apps-dev --registry .azurecr.io +npm run azure -- init sql-apps.json --location westus2 --resource-group rg-sql-apps-poc --registry .azurecr.io npm run azure -- deploy-all sql-apps.json ``` +`init` writes a role-based-data configuration: free-limit serverless SQL, +scale-to-zero gateway and DAB containers and capped logs. It reads +`requiredRole` and `readinessPath` from `role-based-data.json`. + `deploy-all` without `--approve` only lists the pending paid steps (`identity`, `artifacts`, `provision`, `deploy`, `status`), the exclusions and an id. Review -costs (`demo-cost`/`role-based-cost`) and those steps, then, after explicit +costs with the Azure Cost plugin and those steps, then, after explicit approval, run `deploy-all sql-apps.json --approve `. It runs the existing -commands below in order, skips completed ones and refuses a stale id. On a +commands below in order, skips completed ones and refuses a stale id. After +`provision`, it stops for the separately authorized Azure RBAC handoff described +below. Once an administrator completes that handoff, rerun `deploy-all`; it +verifies live assignments before publishing SQL or runtime resources. On a failure, such as no private-network path to SQL before `deploy`, fix the cause -and rerun it to resume. Role assignment and smoke tests stay separate. +and rerun it to resume. Application-role assignment and smoke tests stay +separate. Completed stages are skipped only when their saved fingerprint still matches -the configuration and, for `role-based-data`, the SQL/DAB source. Changed image -digests or role-based schema inputs stop `deploy-all` before any child command. -Review that release change separately; do not delete saved state to force a -resume or treat an old healthy gateway as the changed release. +the configuration and the SQL/DAB source. A changed configuration or source +makes `artifacts` and `deploy` pending again, with a new approval id to review. +Do not delete saved state to force a resume or treat an old healthy gateway as +the changed release. + +## Updating a deployment + +To release a change to an existing deployment: + +1. Edit the SQL project, `dab/dab-config.json` or the deployment configuration. +2. Run `npm run azure -- artifacts sql-apps.json`. It rebuilds both images, retags pinned digests and records the new `imageSource`. +3. Run `npm run azure -- deploy sql-apps.json`. + +`deploy` refuses images built from different SQL/DAB source than the current checkout (DAB bakes `dab-config.json` into its image); run `artifacts` again, then `deploy`. When the configuration or SQL/DAB source changed, `deploy` rolls every stage forward (infrastructure, non-destructive schema publication, runtime and readiness); an unchanged configuration resumes from its saved stage. Verify with `status` and `role-based-smoke` afterward. + +If a maintained deployment route (Azure skills or `azd`) applies the schema instead, run `npm run azure -- grants sql-apps.json`. It writes `dist/sql/role-based-grants.sql`, the exact DAB stored-procedure `EXECUTE` and `VIEW DEFINITION` grants SQL Apps applies, for that route to apply after it creates the DAB database user. ## Entra configuration Copy [the example](../../sql-apps.example.json) to `sql-apps.json`, or use `init` above. Fill in the target tenant/subscription, SQL administrator, resource group/region, and images. -The starter uses `"name": "sqlapps"`. With environment `dev`, the gateway's Azure-generated hostname starts with `sqlapps-dev-gateway.`; the Functions hostname starts with `sqlapps-dev-fn-`. Azure supplies the remaining domain/suffix, so use the actual deployment outputs rather than constructing a URL yourself. Image repository names can remain `sql-apps`; they do not determine the browser URL. +The starter uses `"name": "sqlapps"`. With environment `dev`, the gateway's Azure-generated hostname starts with `sqlapps-dev-gateway.`. Azure supplies the remaining domain/suffix, so use the actual deployment outputs rather than constructing a URL yourself. Image repository names can remain `sql-apps`; they do not determine the browser URL. An existing configuration with a different `name` still selects resources using that prefix. Review it before deployment. Changing the name creates/selects a different resource set and does not rename or migrate an existing app, database or URL. Preserve existing data and review Entra redirects and dependent endpoints before a separate migration. @@ -96,9 +111,9 @@ Create a dedicated app registration once: npm run azure -- identity sql-apps.json ``` -This is an explicit create operation, not an idempotent lookup by display name. It creates one single-tenant SPA/API registration, a delegated `access_as_user` scope, a `Function.Invoke` application role, and a service principal. When the configuration came from `init` (placeholder `apiClientId`), the printed ID is saved into it; otherwise replace `apiClientId` in your real configuration with the printed ID. +This is an explicit create operation, not an idempotent lookup by display name. It creates one single-tenant SPA/API registration, a delegated `access_as_user` scope, the configured human application role such as `AppUser` (allowed member type `User`), and a service principal. When the configuration came from `init` (placeholder `apiClientId`), the printed ID is saved into it; otherwise replace `apiClientId` in your real configuration with the printed ID. -For a `role-based-data` configuration, `identity` instead creates the configured human role such as `AppUser` with allowed member type `User`; `role-based-assign` explicitly assigns approved users/groups. Verify API access-token role claims, trusted gateway-selected DAB role, entity/procedure permissions and SQL grants end to end with `role-based-smoke` and browser acceptance. Local simulation and app-side role enforcement alone do not prove Entra/cloud authorization. +`role-based-assign` explicitly assigns approved users/groups. Verify API access-token role claims, trusted gateway-selected DAB role, procedure permissions and SQL grants end to end with `role-based-smoke` and browser acceptance. Local simulation and app-side role enforcement alone do not prove Entra/cloud authorization. An account email address is not a tenant ID. Confirm the intended directory's tenant ID, or retrieve it with an authorized read-only account query, before tenant-specific sign-in. Do not change subscription defaults or repeat generic login instructions to resolve that mismatch. @@ -107,19 +122,49 @@ The registration requests v2 access tokens. The same registration is used by the Alternatively configure an existing dedicated registration with the same scope and application role: - Delegated scope ID: `6bb296dd-f397-4dcc-a69b-d37dfce3c555`, value `access_as_user`. -- Application role ID: `15d91b1c-83c7-4cb6-b349-d7b8fd4b1425`, value `Function.Invoke`, allowed member type `Application`. +- An enabled application role whose value matches `requiredRole`, allowed member type `User`. - Identifier URI: `api://`. - Sign-in audience: `AzureADMyOrg`; requested access-token version: `2`. -The deployment operator needs directory rights to update redirects and grant that application role to the gateway managed identity. Graph permissions and Azure resource RBAC are separate. +The deployment operator needs directory rights to update redirects. Graph permissions and Azure resource RBAC are separate. -## Private-network SQL deployment +## SQL network deployment -The Bicep template disables SQL's public endpoint and provisions a private endpoint. Run full deployment on a machine/runner that can reach the new VNet through peering/VPN or another approved network path. The `networkId` deployment output identifies it. The default network range is `10.42.0.0/16`; review conflicts before deployment. +SQL's public endpoint is disabled and a private endpoint is retained. +Run schema deployment on a machine/runner that can reach the VNet through +peering/VPN or another approved network path. The `networkId` deployment +output identifies it. The default network range is `10.42.0.0/16`; review +conflicts before deployment. -The configured `sqlAdminObjectId` must identify your migration principal or an Entra group containing it. The CLI obtains a SQL access token using Azure CLI for the configured subscription and passes it to SqlPackage without saving it. Check the selected SQL identity explicitly; Azure resource permissions do not automatically authorize database schema publishing. Error output is redacted for this token. As with SqlPackage's standard access-token interface, the deployment host must be trusted because privileged local processes can inspect process arguments. +The configured `sqlAdminObjectId` must identify your migration principal or an Entra group containing it. SqlPackage signs in with `Authentication=Active Directory Default`, pinned to your Azure CLI sign-in (`AZURE_TOKEN_CREDENTIALS=AzureCliCredential`) and the configured tenant, so no access token is passed on the command line or saved. Sign in with `az login --tenant ` first. Check the selected SQL identity explicitly; Azure resource permissions do not automatically authorize database schema publishing. -No public SQL firewall exception is added to make migrations pass. Use `provision` to create the infrastructure and obtain its network ID before connecting a runner. If full deployment's SQL publishing fails, infrastructure stage state is retained, and deployment stops before gateway/data rollout. Restore private connectivity and retry. +No public SQL firewall exception is added to make migrations pass. Use +`provision` to create infrastructure and obtain its network ID before +connecting a private runner. If schema publishing fails, infrastructure stage +state is retained and deployment stops before gateway/data rollout. + +## Privileged Azure RBAC handoff + +The ordinary infrastructure template does not create Azure role assignments +and does not require `Microsoft.Authorization/roleAssignments/write`. After +`provision`, give an authorized administrator the unchanged configuration, +checkout, and generated deployment state. The administrator must preview and +apply only the dedicated RBAC template: + +```powershell +npm run azure -- rbac-plan sql-apps.json +npm run azure -- rbac-deploy sql-apps.json +``` + +This grants only the gateway and DAB managed identities ACR pull. +`rbac-deploy` reads the resulting assignments back from their exact +resource scopes and fails if any principal, role, or scope does not match. +Broader inherited grants do not satisfy this least-privilege contract. + +The ordinary operator then runs `deploy`. It performs the same live read-back +before schema publication, so copying state or merely claiming that the RBAC +deployment ran cannot bypass the gate. Neither command persists credentials or +records an RBAC-success stage. ## Deployment commands @@ -128,59 +173,51 @@ npm run azure -- validate sql-apps.json npm run azure -- artifacts sql-apps.json npm run azure -- plan sql-apps.json npm run azure -- provision sql-apps.json +npm run azure -- rbac-plan sql-apps.json +npm run azure -- rbac-deploy sql-apps.json npm run azure -- deploy sql-apps.json npm run azure -- status sql-apps.json npm run azure -- schema sql-apps.json -npm run azure -- smoke sql-apps.json -npm run azure -- gateway sql-apps.json -npm run azure -- functions sql-apps.json -npm run azure -- static sql-apps.json -npm run azure -- secret-set sql-apps.json example-secret +npm run azure -- grants sql-apps.json +npm run azure -- redirect sql-apps.json +npm run azure -- role-based-assign sql-apps.json +npm run azure -- role-based-smoke sql-apps.json ``` -Preflight checks cloud/tenant, Bicep/SqlPackage availability, registration shape, registry existence, availability of all three images (and matching digests where specified), and required registered resource providers. Static-only builds skip the prebuilt-image check because their output does not exist yet. It does not prove all resource/Graph permissions, SKU availability, network reachability, or quota. ARM/Graph/SqlPackage failures remain explicit. - -`gateway` and `functions` update only the selected service to its configured prebuilt image; they do not build images or publish SQL. Verify readiness and authenticated smoke afterward. +Preflight checks cloud/tenant, Bicep/SqlPackage availability, registration shape, registry existence, availability of both images (and matching digests where specified), and required registered resource providers. It does not prove all resource/Graph permissions, SKU availability, network reachability, or quota. ARM/Graph/SqlPackage failures remain explicit. -`static` requires the currently deployed gateway image to be an immutable ACR digest and `gatewayImage` in configuration to specify a new version tag. It builds frontend assets with esbuild, layers only `public/` onto that existing runtime digest using an ACR build, resolves the result to a digest, and updates the gateway with that digest. It then atomically updates the configuration's gateway image. It does not rebuild backend code or publish SQL. - -`secret-set` reads `SQL_APPS_SECRET_VALUE` from the trusted deployment process. Never place a secret value in the configuration or command history. See [operations and release checks](../maintainers/operations.md) for the operator's Key Vault permissions and the server-side secret helper. +`schema` republishes only the DACPAC and procedure grants against the deployed database; destructive changes remain blocked. `redirect` re-adds the exact SPA callback on the deployed gateway origin without removing existing redirects. Deployment sequence: 1. Provision/update infrastructure and durable identities without starting new data/gateway apps. -2. Publish the DACPAC with `BlockOnPossibleDataLoss=True` and `DropObjectsNotInSource=False`. -3. Grant the gateway's function invocation role if absent. -4. Deploy data and gateway apps. -5. Add the exact HTTPS SPA callback at `/auth/redirect.html` on the actual gateway origin, preserving existing redirect URIs. -6. Probe data-service readiness through the gateway. +2. Verify the ACR pull assignments, then publish the DACPAC with `BlockOnPossibleDataLoss=True` and `DropObjectsNotInSource=False`, granting the DAB identity only the authorized procedure permissions. +3. Deploy data and gateway apps. +4. Add the exact HTTPS SPA callback at `/auth/redirect.html` on the actual gateway origin, preserving existing redirect URIs. +5. Probe data-service readiness through the gateway. Local state is keyed by subscription/group/app/environment under `.sql-apps`. A local exclusive lock prevents simultaneous commands from this checkout. CI must also use an environment concurrency group. -Readiness checks do not authenticate a user or exercise data/files/functions. Set `SQL_APPS_USER_TOKEN` and `SQL_APPS_SECOND_USER_TOKEN` to short-lived delegated access tokens for two distinct users, then run `smoke`. For both users it reads the `FileJob` entity over REST and GraphQL through the gateway, so the gateway -> DAB -> SQL path, the database policy and the excluded `blob_key` field are all proven on the deployed environment. It then checks file roundtrip, cross-user file denial, function invocation, anonymous rejection of `/auth/me`, `/api/FileJob` and `/graphql`, and cleanup. It never persists tokens. Obtain tokens through your approved sign-in tooling; do not put them in the configuration file or shell history. - -The smoke reads data but does not write it: DAB grants the foundation entity read-only, so cross-user *row* isolation under writes remains a live acceptance step below. The smoke creates uniquely named files and cleans them up on success/failure. Cleanup failures are reported, not ignored. It also checks direct private Function endpoint rejection, so run it from a trusted network-connected machine. It does not replace the other live acceptance steps below. +Readiness checks do not authenticate a user or exercise domain operations. Set `SQL_APPS_USER_TOKEN` to a delegated access token for a user with the required role and `SQL_APPS_SECOND_USER_TOKEN` to one for a user without it, then run `role-based-smoke`. It checks real authorized procedure execution, denial for the user without the role, anonymous denial and rejection of caller-forged role headers. It does not create domain records and never persists tokens. Obtain tokens through your approved sign-in tooling; do not put them in the configuration file or shell history. ## Live acceptance -Use two distinct Entra workforce users: +Use two distinct Entra workforce users, one with the required role and one without: -1. Sign in as A and create/update an application-specific record. Sign in as B in another browser profile; A's row must be absent. Test direct REST and GraphQL operations as both users. The foundation's file/function smoke is not a substitute for domain-specific database acceptance. -2. Try setting/changing `owner_oid`; DAB must reject excluded fields. Test insert/update block predicates through an authorized SQL test session with `oid` and `scp` session context. -3. Upload a file as A. B requesting/deleting the same filename must not access A's file. Test overwrite and the 4 MiB limit. -4. Invoke `/functions/echo` with a user token. Call the private Function endpoint directly with that token from a network-connected test host; it must reject it. A missing/wrong application role must also fail. -5. Reject missing, expired, wrong-audience, and wrong-tenant tokens. Confirm logout clears the browser account state. -6. Redeploy without changes; resource IDs, rows, and files must remain. Test an additive schema change and a destructive change blocked by SqlPackage. -7. Test dependency outage/readiness, runtime replica replacement, and role propagation. Record exact commands/results, not just screenshots. -8. Run DAB's full configuration/database validation in the private environment. JSON schema validation alone is insufficient. +1. Sign in as the authorized user and create/update an application-specific record; reload to confirm persistence. Test direct REST and GraphQL operations where the app exposes them. `role-based-smoke` is not a substitute for domain-specific database acceptance. +2. Sign in as the user without the role. `/auth/me` must report access not allowed, and domain requests must return HTTP 403. Where the app has owner rules, confirm one authorized user cannot read or change another's rows. +3. Reject missing, expired, wrong-audience, and wrong-tenant tokens. Confirm logout clears the browser account state. +4. Redeploy without changes; resource IDs and rows must remain. Test an additive schema change and a destructive change blocked by SqlPackage. +5. Test dependency outage/readiness, runtime replica replacement, and role propagation. Record exact commands/results, not just screenshots. +6. Run DAB's full configuration/database validation in the private environment. JSON schema validation alone is insufficient. -No live acceptance is implied by `npm test`; its Azure/Blob/DAB dependencies are controlled test doubles. +No live acceptance is implied by `npm test`; its Azure/DAB dependencies are controlled test doubles. ## Browser sign-in contract Follow **Build -> Sign in -> Give access -> Publish** and the [canonical authentication reference](authentication.md). The dedicated MSAL v5 bridge is `/auth/redirect.html`, not the application root. Register the exact HTTPS callback on the actual deployed gateway origin, including its path, before accepting a new browser build with an existing registration. Preserve previously approved redirect URIs. The bridge runs only its redirect script, makes no application API calls and must not receive a `Cross-Origin-Opener-Policy` header. -Public browser configuration exposes only the minimal sign-in settings, never tokens, storage credentials or service secrets. The gateway verifies signature, issuer, audience, expiry, tenant, `oid` and the delegated `access_as_user` scope. Required human application roles come from verified token claims, not caller headers. Verify approved role assignments, DAB entity/procedure permissions, SQL grants and any `oid`/`tid` owner restrictions end to end. +Public browser configuration exposes only the minimal sign-in settings, never tokens or service secrets. The gateway verifies signature, issuer, audience, expiry, tenant, `oid` and the delegated `access_as_user` scope. Required human application roles come from verified token claims, not caller headers. Verify approved role assignments, DAB procedure permissions, SQL grants and any `oid`/`tid` owner restrictions end to end. `/auth/me` reports HTTP 200 verified identity and access even for an account without the required role; its domain requests receive HTTP 403 before DAB. An access-denied account can still sign out. HTTP 401 clears owner-specific browser data and stops authenticated requests; HTTP 403 is an operation denial, not logout. A backend outage remains a separate retryable failure and keeps the authorized account signed in. diff --git a/docs/reference/guide.md b/docs/reference/guide.md index ad01881..42094ac 100644 --- a/docs/reference/guide.md +++ b/docs/reference/guide.md @@ -33,13 +33,13 @@ Example input: } ``` -Required text fields are nonblank, at most 500 characters, without control characters. `records` and `actions` contain 1-12 unique nonblank strings. `capabilities` must include `data`; optional values are `files` and `background-jobs`. The existing file processor requires `files` with `background-jobs`. +Required text fields are nonblank, at most 500 characters, without control characters. `records` and `actions` contain 1-12 unique nonblank strings. `capabilities` must be exactly `["data"]`. File storage or background jobs are outside SQL Apps; add them with the maintained Azure skills (for example `azure-storage`, `azure-prepare` and `azure-deploy`). -These are scope decisions, not switches that dynamically compose services. The foundation `app` command still starts its full file/job stack. Role-authorized data apps use the configured [role-based-data profile](role-based-data.md), `role-based-app` and `role-based-serve`; `serve-sql` remains an advanced SQL-only gateway. The selected Todo reference has a separate synthetic-only startup. See [Run locally](../guides/run-locally.md). +The brief is a scope decision, not a switch that composes services. Apps use the [role-based-data profile](role-based-data.md): `app` (alias `role-based-app`) starts SQL, Data API builder and the browser gateway, and `serve` (alias `role-based-serve`) resumes the gateway. The selected Todo reference has a separate synthetic-only startup. See [Run locally](../guides/run-locally.md). Optional `costPreference` is `zero-azure-spend` or `review-paid-costs`. It is never spending consent. -The report's `profile` is `role-based-data` when `role-based-data.json` exists in the checkout, otherwise `null`. `suggestedProfile` is that configured profile; without one, a saved brief suggests `role-based-data` for data-only capabilities and `foundation` when it includes `files` or `background-jobs` (`null` before a brief). The suggestion fills the `setup plan --profile` hint and selects `role-based-cost` or `demo-cost` for the cost review. It is advice, not configuration: a suggested `role-based-data` still needs `role-based-data.json`, which `setup plan` reports. +The report's `profile` is `role-based-data` when `role-based-data.json` exists in the checkout, otherwise `null`. `suggestedProfile` is always `role-based-data`, the only profile. Cloud cost review routes to the Azure Cost plugin. The suggestion is advice, not configuration: it still needs `role-based-data.json`, which `setup plan` reports. ## Record historical evidence diff --git a/docs/reference/local-consent.md b/docs/reference/local-consent.md index 8f2542b..f2c2cad 100644 --- a/docs/reference/local-consent.md +++ b/docs/reference/local-consent.md @@ -16,9 +16,10 @@ contain no credentials. Re-probe tools after installation/restart. 1. Confirm the canonical application checkout and inspect setup read-only. 2. Install missing tools only, each with its own reviewed plan. -3. Run `setup approval --profile `. It lists restore, build, workspace - selection, image downloads, SQL startup, schema, services and the SQL terms - line together, plus what is excluded. Present it once. +3. Run `setup approval --profile role-based-data --workspace `. It + lists restore, build, workspace selection, image downloads, SQL startup, + schema, services, the read-only SQL acceptance check and the SQL terms line + together, plus what is excluded. Present it once. 4. After explicit approval, run the printed `setup approve ... --approval `. It records every listed grant and the inline terms acknowledgement under one lock and refuses a stale id. Then `setup run`. @@ -86,10 +87,9 @@ For an existing fixed-port stack, explicitly choose `legacy` instead. This only records a descriptor; it does not authorize startup, migrate data or delete it. Creating the descriptor preserves checkout-level restore/build grants. -Once selected, plan the chosen startup, such as `role-based-app` or `app`. +Once selected, plan the startup operation `app` (`role-based-app` is an alias). The plan lists all of its scopes before any startup side effect. Record that reviewed plan once rather than repeating prompts after each completed stage. -Do not use the full foundation startup for data-only scope. ### Terms acknowledgement @@ -136,12 +136,10 @@ On Windows, concurrent removal can report `EPERM` after the old directory has already disappeared; recovery proceeds only after confirming that absence. Permission errors for a still-existing lock remain explicit failures. -`recover-sql` and manual `maintain` need `grant-once `, not a -persistent grant. After explicit approval, the direct runtime CLI consumes the -grant **before** dispatch; even a failed operation needs renewed approval. -Recovery still refuses running/external/legacy SQL and preserves image, -credentials and volume. Maintenance can delete expired jobs, snapshots and -traces; review the configured policy and its exact target first. +`recover-sql` needs `grant-once `, not a persistent grant. +After explicit approval, the direct runtime CLI consumes the grant **before** +dispatch; even a failed operation needs renewed approval. Recovery still refuses +running/external/legacy SQL and preserves image, credentials and volume. ## Scope and invalidation @@ -152,10 +150,9 @@ traces; review the configured policy and its exact target first. | `workspace` | Descriptor creation for the explicit checkout and selected port block or legacy mode. | | `sql-start` | Start/create owned SQL resources and download its image, with matching reviewed terms. | | `schema` | Non-destructive schema/grant publication to the declared local database; changes to the schema launcher or role-based profile code require review, application SQL edits do not. | -| `services` | Start/reuse the declared loopback profile and its image/configuration effects. Foundation scope includes background processing and timed recovery/retention cleanup. Changed launcher, worker or processing settings require review; DAB configuration and data profile edits do not. | -| `synthetic` | The exact acceptance suite, fixtures and cleanup. Foundation `app`, bare `init` and `data` also run built-in SQL acceptance writes. | -| `stop` | Stop/remove verified owned services, preserving SQL/storage volumes and credentials. | -| `maintenance` | One-operation manual retention cleanup and stale-job recovery, not blanket deletion approval. | +| `services` | Start/reuse the role-based-data loopback DAB container and browser gateway with their image/configuration effects. Changed launcher code requires review; DAB configuration and `role-based-data.json` edits do not. | +| `synthetic` | The exact acceptance suite, fixtures and cleanup. `app` and bare `init` also run the built-in read-only SQL acceptance check on the least-privilege app user. | +| `stop` | Stop/remove verified owned services, preserving SQL volumes and credentials. | Records live in ignored `.sql-apps/consent.json` and `.sql-apps/terms.json`. They contain no credentials or actual application records. Requirements bind diff --git a/docs/reference/local-development.md b/docs/reference/local-development.md index 5866b5d..217dab8 100644 --- a/docs/reference/local-development.md +++ b/docs/reference/local-development.md @@ -1,18 +1,20 @@ # Local runtime reference -The local inner loop uses the Azure SQL Database container (Private Preview, **EngineEdition 5**), Microsoft.Build.Sql/SqlPackage, Data API builder 2.0.12, Azurite 3.35.0 Blob/Queue storage, and the real Azure Functions v4 Node.js 22 host. It does not use a SQL Server substitute, cloud resources, or an upstream hosted runtime. +The local inner loop uses the Azure SQL Database container (Private Preview, **EngineEdition 5**), Microsoft.Build.Sql/SqlPackage, Data API builder 2.0.12 and a loopback browser gateway. It does not use a SQL Server substitute, cloud resources, or an upstream hosted runtime. -For the shortest startup path, use [Run locally](../guides/run-locally.md). This reference covers individual services, workspace ownership, file processing and recovery. If you have a pre-rebrand checkout, read [migration notes](../maintainers/naming-transition.md). +For the shortest startup path, use [Run locally](../guides/run-locally.md). This reference covers individual commands, workspace ownership, diagnostics and recovery. If you have a pre-rebrand checkout, read [migration notes](../maintainers/naming-transition.md). -## Capability-scoped startup +## One local profile -The foundation `app` command starts SQL/DAB/storage/Functions/browser; saved brief capabilities do not turn services off. A role-authorized data application uses the [role-based-data profile](role-based-data.md), `role-based-app` and `role-based-serve` with the same explicit workspace/container selection. This stages SQL/schema/DAB/browser startup without storage/Functions and checks the configured procedure before listening. +SQL Apps runs one profile locally and in the cloud: [role-based-data](role-based-data.md). `app` (alias `role-based-app`) starts SQL, publishes the schema, starts Data API builder (DAB) and serves the browser gateway, then checks the configured authorized readiness procedure before listening. `serve` (alias `role-based-serve`) resumes the gateway against running SQL/DAB. Both read `role-based-data.json` and the authorized stored-procedure entities in `dab/dab-config.json`. Saved brief capabilities do not turn services on or off; the brief's capabilities are always `["data"]`. -`serve-sql` remains an advanced SQL-only gateway, not the configured role-based-profile launcher. Implement domain UI/schema/procedures, configure authorized DAB permissions, remove excluded controls, and verify the real SQL procedure/browser save-reload flow. The selected reference path below is anonymous synthetic-only, not a role-based-app substitute. The [role-based-data Azure path](role-based-data.md#identity-artifacts-and-deployment) selects matching resources while preserving private SQL. +SQL Apps does not run file storage, queues, background workers or tracing. Add those with the maintained Azure skills (for example `azure-storage`, `azure-prepare` and `azure-deploy`), outside SQL Apps. + +Implement domain UI/schema/procedures, configure authorized DAB permissions and verify the real SQL procedure/browser save-reload flow. The selected reference path below is anonymous synthetic-only, not a substitute for your app. The [role-based-data Azure path](role-based-data.md#identity-artifacts-and-deployment) selects matching resources while preserving private SQL. Follow [scoped consent and readiness](../guides/run-locally.md#approvals-and-readiness). A proposed origin or successful build is not a running app. Publish the intended origin only after response/readiness probes, then report workflow acceptance separately. -Local mutations now enforce [remembered permissions](local-consent.md) before dispatch, including when invoked directly. Obtain them with the single `setup approval` bundle (or individual grants from the pre-build operation plan) before the commands below. Read-only checks remain ungated; restore/build grants and existing workspace selection are reused across sessions. +Local mutations enforce [remembered permissions](local-consent.md) before dispatch, including when invoked directly. Obtain them with the single `setup approval` bundle (or individual grants from the pre-build operation plan) before the commands below. Read-only checks remain ungated; restore/build grants and existing workspace selection are reused across sessions. ## Prerequisites @@ -22,8 +24,8 @@ First time installing development tools? Use [guided setup](../guides/getting-st - .NET SDK 8 or later. The CLI restores this repository's SqlPackage tool. - Docker with Linux containers. The SQL preview image requires x64; `start-sql` requests `linux/amd64`, including on emulated hosts. - Access to the current preview container image, or an already running current Azure SQL container. Obtain preview access using the [official announcement](https://devblogs.microsoft.com/azure-sql/azure-sql-database-container-prpr/) and its linked instructions. This project does not distribute registry credentials or circumvent preview access. -- A free selected workspace port block. Explicit legacy compatibility uses 15000 (DAB), 18080 (gateway), 10000/10001 (Blob/Queue), and 17071 (Functions). -- Initial access to download the pinned public Azurite/Functions/Node images, npm packages, .NET SQL build dependencies and SqlPackage. Functions Core Tools need not be installed: the project runs the real host in its pinned container image. +- A free selected workspace port block. Explicit legacy compatibility uses 18080 (gateway) and 15000 (DAB). +- Initial access to download the pinned DAB image, npm packages, .NET SQL build dependencies and SqlPackage. No Azure CLI login, subscription, Entra registration, or billable resources are required for default local preview. The explicit [real Entra sign-in opt-in](authentication.md#real-entra-sign-in-locally) requires a development registration, not an Azure deployment. First use needs package/image downloads; cached dependencies and images can be used afterward. @@ -39,9 +41,11 @@ npm run local -- workspace-plan ### Select local workspace ownership -`workspace-plan` proposes a deterministic six-port block from the canonical checkout path and reports conflicts plus a free alternative, without writing state or changing Docker. Approve `npm run local -- workspace-init ` using the proposed block for a new isolated stack. The selected descriptor persists in `.sql-apps/workspace.json`; resource names, SQL/storage credentials, DAB config and settings are derived from the workspace ID. A descriptor copied from another checkout fails closed. Free-port observations are not reservations: startup rechecks conflicts, and bind races remain explicit failures. +`workspace-plan` proposes a deterministic port block from the canonical checkout path and reports conflicts plus a free alternative, without writing state or changing Docker. Within the block, the gateway uses base+0, DAB base+1 and SQL base+5; these fixed offsets keep existing isolated workspaces on their SQL port. Approve `npm run local -- workspace-init ` using the proposed block for a new isolated stack. The selected descriptor persists in `.sql-apps/workspace.json`; resource names, SQL credentials and DAB configuration are derived from the workspace ID. A descriptor copied from another checkout fails closed. Free-port observations are not reservations: startup rechecks conflicts, and bind races remain explicit failures. + +For an existing legacy stack, approve `npm run local -- workspace-init legacy` instead. This preserves its names, ports (gateway 18080, DAB 15000), credential paths and volumes. Do not select isolation expecting old data to move automatically. The legacy stack remains separately available; no automatic transfer or schema cleanup is performed. `workspace-check` reports the selected mode, ports and origins. -For an existing legacy stack, approve `npm run local -- workspace-init legacy` instead. This preserves its names, ports, credential paths and volumes. Do not select isolation expecting old data to move automatically. The legacy stack remains separately available; no automatic transfer or schema cleanup is performed. `workspace-check` reports the selected mode, ports and origins. +Workspace identity uses the native resolved checkout path, including expansion of Windows 8.3 short names. Short and long aliases of the same checkout therefore select the same workspace. Existing descriptors with a different recorded home or ID still fail closed; they are not rewritten automatically because that would change resource ownership. Isolated mode also derives a database and least-privilege SQL login from the workspace ID. Explicitly reusing the same external SQL container from two workspaces therefore does not implicitly share their application database/login. Existing legacy databases are not renamed or copied. @@ -53,11 +57,11 @@ After selection and startup approval: npm run local -- app ``` -After the intended server responds and `/health/ready` passes, open the URL printed by startup (**http://127.0.0.1:18080** in legacy mode) and select **Open local preview**. For approved foundation file/job scope, use file upload/list/download/delete, Function invocation and queued file processing. For separate two-user ownership checks, open the reported developer page in the same tab, switch test identities and return to the application. Switching revokes the previous session; each user sees only their own data, files and jobs. Reloading preserves the session within the browser tab; SQL rows and Blob files persist independently of browser sessions. +`app` runs four stages and names the stage that fails: **start-sql** (start/reuse the project-owned SQL container), **init** (publish the schema and grants and run the read-only SQL acceptance check), **data** (recreate only the project-labeled DAB container) and **serve** (start the browser gateway on a single loopback origin). It stays in the foreground. Ctrl+C stops the browser gateway; SQL and DAB keep running. In preview mode, open local preview again after restarting the browser server because its sessions are held only in server memory. -`app` starts/reuses the project-owned SQL container, publishes the schema, runs SQL security checks, recreates local DAB, starts persistent Azurite, builds/starts the local Functions image, and serves the existing frontend and gateway on a single loopback origin. It stays in the foreground. Ctrl+C stops the browser gateway; the service containers continue running. In preview mode, open local preview again after restarting the browser server because its sessions are held only in server memory. +After the intended server responds and `/health/ready` passes, open the URL printed by startup (**http://127.0.0.1:18080** in legacy mode) and select **Open local preview**. The starter page calls the configured `readinessPath` (default `/api/AppReady`) with the required role. For authorization checks, open the reported developer page in the same tab: Development Alice has the required role and Development Bob does not. Switching revokes the previous session. Reloading preserves the session within the browser tab; SQL rows persist independently of browser sessions. -For individual SQL/data steps and API acceptance: +For individual SQL/data steps: ```powershell npm ci @@ -65,35 +69,32 @@ npm run build npm run local -- start-sql npm run local -- init npm run local -- data -npm run local -- api-test +npm run local -- serve ``` -Use `npm run local -- services` after the individual SQL/data steps, then `npm run local -- serve` to start only the browser gateway against running services. Pass the same optional SQL container name to every command when using a non-default container. `npm run local -- serve-sql` starts the optional SQL-only browser with unavailable file/function/processing controls, without requiring service credentials. +Pass the same optional SQL container name to every command when using a non-default container. ### Initial readiness and later outages -Every local gateway checks its included dependencies before listening. Foundation readiness requires an authorized, bounded `FileJob` read through DAB, properties of the required Blob containers and queues, and a correctly authenticated Functions echo with the expected result. These probes do not create containers, queues or business records. DAB's anonymous aggregate `/health` result is not the SQL verdict: its anonymous REST check can be Forbidden while the authorized application query succeeds. +Every local gateway checks the configured readiness procedure through DAB with the required role before listening. The probe does not create business records. DAB's anonymous aggregate `/health` result is not the SQL verdict: its anonymous REST check can be Forbidden while the authorized procedure works. -`serve` reuses existing services and credentials; it never starts or recreates dependencies. It makes at most three read-only readiness attempts, with a four-second shared deadline per attempt and one-second pauses between failures. SQL-only readiness checks only SQL/DAB; the role-based profile checks its configured authorized procedure without foundation services. After listening, a separate actual HTTP readiness confirmation has a five-second caller timeout. Initial failure closes the new gateway, names the failed dependency and publishes no ready URL. These checks are not service-repair loops or retries of failed image builds. +`serve` reuses existing SQL/DAB and credentials; it never starts or recreates dependencies. It makes at most three read-only readiness attempts, with a four-second shared deadline per attempt and one-second pauses between failures. After listening, a separate actual HTTP readiness confirmation has a five-second caller timeout. Initial failure closes the new gateway, names the failed dependency and publishes no ready URL. These checks are not service-repair loops. -An already-running gateway stays reachable during a later outage: `/health/live` remains HTTP 200 and `/health/ready` returns HTTP 503 with `status: not-ready` and a safe `data`, `blob`, `queue` or `functions` dependency identifier. Worker failure alone does not disable durable job submission or erase queued work when SQL and storage remain available. Configured browser capabilities are not instantaneous health guarantees; operation failures remain explicit. The next successful readiness probe recovers in place without a gateway restart. +An already-running gateway stays reachable during a later outage: `/health/live` remains HTTP 200 and `/health/ready` returns HTTP 503 with `status: not-ready` and the safe `data` dependency identifier. Operation failures remain explicit. The next successful readiness probe recovers in place without a gateway restart. -Read the named failure and inspect the affected dependency before separately approved recovery. Do not use full `app` or `services` startup as a read-only diagnostic: those commands can recreate owned DAB/storage/Functions resources. Setup also blocks a matching but unready gateway rather than replacing it or claiming a successful launch. +Read the named failure and inspect the affected dependency before separately approved recovery. Do not use full `app` startup as a read-only diagnostic: it recreates the owned DAB container. Setup also blocks a matching but unready gateway rather than replacing it or claiming a successful launch. With the browser server running, use a second terminal: ```powershell npm run local -- app-test -npm run local -- services-test ``` -`app-test` obtains separate server-issued sessions, checks identity lookup and strict public job shapes, rejects anonymous access, and removes its sessions. `api-test` separately exercises real FileJob REST pagination, GraphQL owner isolation, processor writes and mutation denial directly against DAB, cleaning its fixtures. - -`services-test` checks a real exact-4-MiB upload/download, 4-MiB-plus-one rejection, same-name isolation, HTTP Function authorization, queued/terminal SQL states, immutable snapshots after overwrite/delete, duplicate delivery, retries/poison handling, role-forgery denial and persistence across an Azurite restart. It also verifies owner-scoped OpenTelemetry trace correlation across the gateway and real worker, actual scheduled stale-job recovery, saved-snapshot retry with history, and opt-in retention against isolated fixtures. It also starts the same Functions image on an **internal Docker network with no public egress** and verifies a real queued job completes without downloading bindings. It briefly stops/restarts the project worker and storage; do not run it during other users' processing. Fixtures, sessions and the temporary offline-proof container are cleaned up. +`app-test` obtains separate server-issued sessions for both development users, checks identity lookup, expects HTTP 200 from the readiness path for the user with the required role and HTTP 403 for the user without it, rejects anonymous access, and removes its sessions. ## Explicitly selected synthetic reference -The default commands above keep their file/job behavior. An opt-in selected application uses the same workspace/source checks but a separate application database/login/configuration and route composition. It requires isolated mode; do not overwrite a legacy descriptor or transfer legacy data implicitly. +The default commands above run your application. An opt-in selected application uses the same workspace/source checks but a separate application database/login/configuration and route composition. It requires isolated mode; do not overwrite a legacy descriptor or transfer legacy data implicitly. After explicit reference selection and download/EULA/schema approval: @@ -102,7 +103,7 @@ npm run app:build -- todo local-simulation npm run local -- selected-app "" ``` -Use the exact artifact directory printed by the builder. Startup rechecks source/checksums and occupied gateway/DAB/SQL ports, verifies owned resources, uses existing .NET/SqlPackage publishing without dropping unrelated objects, and probes the actual execute-only SQL login and procedure. It starts only SQL, selected DAB and the browser/API; no Azurite, Functions or cloud registration is required. The selected browser uses anonymous synthetic-data sessions, not the default development identities. +Use the exact artifact directory printed by the builder. Startup rechecks source/checksums and occupied gateway/DAB/SQL ports, verifies owned resources, uses existing .NET/SqlPackage publishing without dropping unrelated objects, and probes the actual execute-only SQL login and procedure. It starts only SQL, selected DAB and the browser/API; no cloud registration is required. The selected browser uses anonymous synthetic-data sessions, not the default development identities. After Ctrl+C, `selected-serve ""` resumes with verified services and SQL-backed sessions. `selected-stop todo` removes only this selection's verified DAB container, preserving SQL data and protected credentials. To recreate that DAB service, use `selected-app` again. Configuration mismatch or existing SQL objects without credential state is an explicit review/repair boundary, never permission to reset credentials or data. @@ -110,68 +111,11 @@ With separate synthetic-test approval, use `selected-test "" See [the reference guide](../../examples/todo/README.md). Local acceptance is not cloud-managed-identity or Azure deployment proof. -## Files and queued processing - -Upload a safe ASCII single-segment name (letters/digits, underscore, hyphen and dot, up to 128 characters; no `..`). Maximum upload size is 4 MiB. Uploading the same name replaces only the selected user's file. Download and delete use the authenticated gateway, never a browser storage key or SAS. - -The file list loads at most 100 entries at a time. Select **Load more files** to request the next owner-scoped page. Job polling returns all active jobs plus the 100 newest completed or failed jobs; maintenance separately scans full history when retention is enabled. - -Click **Process file**. Job progress updates automatically by default; **Refresh jobs** is also available. The gateway reads the selected user's file, saves an immutable private snapshot, creates an owner-defaulted SQL job and sends a base64-encoded Azure Storage Queue message. The real Functions queue trigger updates `queued` -> `processing` -> `completed`, computing SHA-256, byte count and UTF-8 text-line count. Empty content has zero lines; CRLF/CR/LF separators are supported, and a trailing separator counts a final empty line. Binary content is decoded with UTF-8 replacement characters for the text-line metric. - -Completed job details, including the full SHA-256, wrap on narrow screens without clipping or truncating the result. - -The source file can be replaced or deleted after submission without changing the snapshot. Duplicate delivery skips terminal jobs. Queue submission failures persist a failed job and return an explicit error. Processing retries up to five deliveries; exhausted messages enter `file-jobs-poison`, whose handler persists failure and logs the job ID. Inspect worker logs for failures; if the data service is unavailable even through poison-handler retries, recover services and inspect/replay the remaining poison messages rather than assuming successful processing. - -Delete terminal jobs to remove their SQL record and snapshot. Queued/running jobs cannot be deleted (409). A **Retry snapshot** button on failed jobs creates a new job and copies the original private snapshot, even if the source file was deleted or replaced. The old job remains failed, and the new `parent_job_id` links history. Only the owner can retry; missing snapshots return 409 with guidance to upload again. Each explicit retry is a new submission, not automatic business-level deduplication. - -Workers hold an exclusive Blob lease from the authoritative job check through processing and release it on completion or failure. Timeout recovery, job deletion and retention must acquire that same lease before changing the SQL record. Recovery skips a stale job while a live worker holds the lease; deletion and retention report the conflict. - -If SQL deletion fails, the snapshot is preserved. If snapshot cleanup fails after the record is removed, the error is reported; repeating the owner-scoped job DELETE request cleans up the leftover snapshot. That repeat may return 404 because the record is already gone. Opt-in retention uses the same ordering and remains disabled by default. - -The lease has no automatic expiry: a timeout is not proof that the worker stopped. If a worker process crashes or lease release fails, the snapshot stays protected. After explicitly stopping and verifying the owning workspace's worker is no longer running, keep its Azurite instance available and use Storage Explorer's **Break Lease** action on the exact `files///jobs/` blob. Then retry deletion or retention. Never break leases automatically or while a worker may still be running; do not reset storage or credentials to recover. - -### Local feature flags - -Optional settings are read from the selected workspace's gitignored `local-settings.json` (legacy path `.sql-apps\local-settings.json`; isolated path `.sql-apps\workspaces\\local-settings.json`). Copy [local-settings.example.json](../../local-settings.example.json) there and change the values, then restart **both** `services` and the browser gateway. The worker receives validated settings through its environment; settings are startup snapshots, not hot-reloaded. Unknown keys, invalid types and out-of-range values fail startup explicitly. - -| Setting | Default | Behavior | -| --- | --- | --- | -| `processing` | `true` | Allow new processing submissions; disabling it still permits viewing/deleting existing terminal jobs and draining accepted work | -| `automaticProgress` | `true` | Poll job status without manual refresh | -| `jobRetry` | `true` | Allow saved-snapshot retries; also requires processing enabled | -| `tracing` | `true` | Record local OpenTelemetry spans and expose the owner-scoped dashboard | -| `progressIntervalMs` | `2000` | Polling interval, 1,000-30,000 ms | -| `staleJobMinutes` | `60` | Recovery timeout, 5-10,080 minutes | -| `retentionDays` | `0` | **Disabled** by default; 1-365 days enables deletion of expired terminal jobs/snapshots and orphan snapshots | -| `traceRetentionDays` | `7` | Trace expiry, 1-30 days | - -The server enforces processing/retry flags; they do not merely hide buttons. These are subscription-free configuration settings, **not an App Configuration emulator or a deployed cloud feature-flag service**. The production browser keeps these optional local capabilities off. - -Automatic progress uses non-overlapping authenticated requests, pauses while the tab is hidden or a foreground action is busy, backs off to at most 30 seconds on failures, and aborts on page exit. Errors are shown alongside jobs; polling resumes when dependencies recover. This is polling, not a Web PubSub/WebSocket implementation. - -### Recovery and scheduled retention - -The real local Functions **timer trigger runs every minute**, using Azurite schedule monitoring. Jobs left queued/processing beyond the configured timeout are marked failed with recovery guidance. Late queue deliveries skip failed terminal records; explicit retry creates a fresh snapshot/job rather than resetting an old record underneath previous deliveries. An already-running worker can still complete the original job; its lease protects the snapshot until it finishes. The timeout is an operator recovery policy, not proof that a stalled process stopped; keep it comfortably above expected processing duration. - -Setting `retentionDays` above zero enables destructive cleanup of terminal jobs whose last status change is older than the configured number of days. Their SQL rows are deleted before snapshots, preserving snapshots if SQL deletion fails. Orphan snapshots have an additional minimum 24-hour grace period. Active-job snapshots and source uploads are never removed by retention. Traces expire separately after `traceRetentionDays`. Timer failures are logged by Functions and propagate; storage/SQL operations are not an atomic cross-service transaction, so subsequent runs reconcile remaining orphans. - -Run the same policy immediately with: - -```powershell -npm run local -- maintain -``` - -Automatic job retention stays **off** unless you explicitly enable it. There is no automatic expiry for source uploads or per-user quota beyond the per-file limit. This is a learning workflow, not a production retention/compliance system. - -### Local tracing dashboard - -Use **Refresh traces** in the browser to view your latest 100 spans, including operation names, timings, status, trace/span/parent IDs and processing job IDs. The official OpenTelemetry SDK creates spans; a small local exporter persists a selected, sanitized record in a separate Azurite `traces` container. Submission passes trace context through Queue Storage to the actual Functions worker. Gateway request spans use route templates, not arbitrary URL/query content. Routine automatic status polling and trace-dashboard reads are excluded to avoid telemetry amplification. - -The browser only sees traces in its verified owner's namespace. No bearer tokens, account keys, file contents, request bodies or filenames are exported in span records. Trace export failures are explicitly logged but are not treated as failed business operations. This local dashboard is not an Azure Monitor emulator or an OTLP collector, and it does not prove cloud tracing/managed identity behavior. Azure Monitor export is a separate future integration. - ### Development identity boundary -The product uses **Open local preview** and **End preview**, not persona selectors. The server chooses the default fixed simulated identity and issues an unpredictable 43-character opaque bearer token. Authorization and ownership testing uses the separate local-only developer page, `/__dev/identity`, in the same tab. Its fixed Development Alice/Bob test identities carry explicit role labels; switching invalidates the previous session. Sessions expire after eight hours, are limited to 1,000 active sessions and are revoked on switch or **End preview**. +The product uses **Open local preview** and **End preview**, not persona selectors. The server chooses the default fixed simulated identity and issues an unpredictable 43-character opaque bearer token. Authorization testing uses the separate local-only developer page, `/__dev/identity`, in the same tab. Its fixed Development Alice/Bob test identities are labeled as having or lacking the required role; switching invalidates the previous session. Sessions expire after eight hours, are limited to 1,000 active sessions and are revoked on switch or **End preview**. + +A signed-in user without the required role receives HTTP 403 from the gateway for `/api/*` and `/graphql`. `/auth/me` still returns the identity with `access: { allowed, requiredRole }`, so the app can show an access-denied state. The browser never supplies object IDs to the data proxy. The gateway validates the issued preview session or, in explicit local Entra mode, a real delegated access token, strips incoming identity/role headers and creates the trusted local DAB principal server-side. Local DAB remains claim simulation even with real local Entra sign-in; that does not prove cloud DAB identity or Azure service permissions. @@ -179,38 +123,35 @@ The local server binds only to `127.0.0.1`, requires the exact development Host, See [Authentication and application access](authentication.md) for `SQL_APPS_LOCAL_AUTH=entra`, the UUID tenant/client settings, the exact printed `/auth/redirect.html` callback and the separately approved development registration. Invalid Entra configuration or sign-in failure never falls back to simulation. Setup matches authentication mode, tenant and client in addition to ownership and source fingerprint; changing them requires stopping only the intended gateway and restarting with the reviewed configuration, not resetting data or replacing another process. -Azurite and the local Functions entry point use separately generated, gitignored credentials. The browser never receives them. Local HTTP Functions require the gateway's server-only secret and validated user envelope. Production Functions still require the exact Entra gateway service principal and application role; that verifier is not weakened. Local startup refuses production mode. +Do not deploy the local generated DAB configuration to Azure. -The local DAB `FileJob` entity allows ordinary simulated users only owner-scoped reads, excluding snapshot keys. Only the trusted server-side processor role creates/updates/deletes job records; the gateway strips caller principal/role headers. SQL filter/block predicates enforce ownership for both roles. The shared SQL project contains the job table, but production DAB does **not** expose it and cloud job processing is not enabled. Do not deploy the local image or generated DAB configuration to Azure. +## SQL container and schema -`start-sql` creates `sql-apps-sql` using the current canonical `azure-sql/db-dev:latest` preview image. It generates a bootstrap password without printing it, publishes SQL only to a randomly allocated loopback port, and stores data in the `sql-apps-sql-data` named volume. Subsequent runs restart the same project-labeled container instead of replacing it. Docker may use its cached `latest` image; refreshing/upgrading is an explicit operator action, not an automatic destructive upgrade. +`start-sql` creates `sql-apps-sql` using the current canonical `azure-sql/db-dev:latest` preview image. It generates a bootstrap password without printing it, publishes SQL only to a loopback port (the workspace SQL port in isolated mode, a randomly allocated port in legacy mode), and stores data in the `sql-apps-sql-data` named volume. Subsequent runs restart the same project-labeled container instead of replacing it. Docker may use its cached `latest` image; refreshing/upgrading is an explicit operator action, not an automatic destructive upgrade. Readiness includes a fresh completion marker from the current preview image's control-plane initialization, not just an early `SELECT 1`. A measured preview recovery race (error 904 during control-plane reconciliation) receives at most three explicitly logged restart attempts; other failures are not silently reset. An exhausted reconcile failure is reported as preview control-plane recovery, not proof that registry access was revoked. The existing volume is preserved. If a stopped **isolated, workspace-owned** SQL container repeatedly exits during reconciliation, review its bounded logs and obtain approval before `npm run local -- recover-sql`. This recreates only that stopped container, with the same image version, administrator credential, published port and labeled data volume/network. It refuses running, external, legacy or mismatched resources and a changed image tag. The normal `start-sql` command never does this implicitly. -Recovery records the original credential in an atomic, gitignored workspace recovery file before removing the stopped container. Treat local state as sensitive. Successful readiness removes that temporary record. If Docker creation or startup fails, the record remains; explicitly rerun `recover-sql` to resume rather than deleting state, resetting passwords or using ordinary startup to generate a replacement credential. The command does not publish schema or claim application readiness; resume `app` after SQL recovery to refresh DAB/service connections. - -Live two-workspace testing observed this failure on the already-current preview image and verified that explicit container recreation preserved SQL rows and Blob data. A pull is not necessarily a fix: confirm the actual image changed, and do not compare a registry manifest's config digest with a different image-store identifier to infer a newer release. No image refresh or recreation of other running stacks is automatic. +Recovery records the original credential in an atomic, gitignored workspace recovery file before removing the stopped container. Treat local state as sensitive. Successful readiness removes that temporary record. If Docker creation or startup fails, the record remains; explicitly rerun `recover-sql` to resume rather than deleting state, resetting passwords or using ordinary startup to generate a replacement credential. The command does not publish schema or claim application readiness; resume `app` after SQL recovery to refresh the DAB connection. -`init` verifies the engine, creates `sql_apps_local`, builds/publishes the shared DACPAC, creates the mapped `sql_apps_local_dab` application login/user, grants only table CRUD and table `VIEW DEFINITION`, and runs real SQL security acceptance. SA is used only for provisioning, not the application. Publication blocks possible data loss and does not drop unspecified objects. +Live two-workspace testing observed this failure on the already-current preview image and verified that explicit container recreation preserved SQL rows. A pull is not necessarily a fix: confirm the actual image changed, and do not compare a registry manifest's config digest with a different image-store identifier to infer a newer release. No image refresh or recreation of other running stacks is automatic. -`data` reruns SQL acceptance, writes a separate development DAB configuration, and starts DAB at **http://127.0.0.1:15000**. SQL and DAB communicate on the SQL container's Docker network, not through a Docker Desktop-specific hostname. Restart `data` after restarting/recreating SQL, because its container IP may change. +`init` verifies the engine, creates the workspace database, builds/publishes the shared DACPAC, creates the mapped least-privilege application login/user, grants only `EXECUTE` and object-scoped `VIEW DEFINITION` on the authorized DAB procedures, and runs the read-only SQL acceptance check as that user (no `db_owner`, `CONTROL`, `db_datareader` or `db_datawriter`). SA is used only for provisioning, not the application. Publication blocks possible data loss and does not drop unspecified objects. -`api-test` creates fresh identities/fixtures, verifies paginated owner reads, owner updates, cross-user mutation denial, owner-field injection denial, GraphQL create/read isolation, and anonymous access denial. It deletes fixtures afterward. SQL acceptance independently tests block predicates and missing delegated scope inside a rolled-back transaction. +`data` reapplies the procedure grants, writes a separate development DAB configuration, and restarts only the project-labeled DAB container (**http://127.0.0.1:15000** in legacy mode). It does not rerun SQL acceptance; use `npm run local -- test` for that. SQL and DAB communicate on the SQL container's Docker network, not through a Docker Desktop-specific hostname. Restart `data` after restarting/recreating SQL, because its container IP may change. -After changing schema, shared DAB config, or code: +After changing schema, DAB configuration, `role-based-data.json` or code: ```powershell npm run build npm run local -- init npm run local -- data -npm run local -- services -npm run local -- api-test +npm run local -- serve npm test ``` -Repeated `init` preserves existing rows. Repeated `data` recreates only the project-labeled DAB container and reloads current configuration; it does not delete SQL data. Run `services` after `data` so the worker uses the current DAB network address. Repeated `services` rebuilds/recreates owned storage/Functions containers while retaining the Azurite volume and credentials. +Repeated `init` preserves existing rows. Repeated `data` recreates only the project-labeled DAB container and reloads current configuration; it does not delete SQL data. ## Reuse an existing SQL container @@ -218,7 +159,7 @@ Repeated `init` preserves existing rows. Repeated `data` recreates only the proj npm run local -- verify sqldbdev npm run local -- init sqldbdev npm run local -- data sqldbdev -npm run local -- api-test +npm run local -- serve sqldbdev ``` The existing container needs a published host SQL port, a Docker bridge network, the bundled `/opt/mssql-tools18/bin/sqlcmd`, and its bootstrap `MSSQL_SA_PASSWORD` environment setting. Initialization reads that credential in memory for schema publication; it does not print it or save it in project state. Run this only on a trusted developer machine, because privileged local processes can inspect Docker environment and SqlPackage arguments. @@ -231,7 +172,7 @@ Some older preview images report EngineEdition 5 but reject `CREATE USER ... FOR npm run local -- start-sql sql-apps-sql-current npm run local -- init sql-apps-sql-current npm run local -- data sql-apps-sql-current -npm run local -- api-test +npm run local -- serve sql-apps-sql-current ``` Legacy mode supports only one DAB instance on port 15000. Separate isolated checkouts use their own port blocks and resources. Within one workspace, switching the selected SQL container still requires review of its downstream data/service resources; never stop another workspace to free a port. @@ -240,66 +181,46 @@ Legacy mode supports only one DAB instance on port 15000. Separate isolated chec Local DAB uses documented [EasyAuth claim simulation](https://learn.microsoft.com/en-us/azure/data-api-builder/concept/security/authenticate-easy-auth), not actual Entra authentication. The local gateway validates an issued opaque session in default preview mode or a verified delegated token in opt-in Entra mode; the production gateway requires verified Entra tokens. Both reject caller-supplied principal headers. Real browser sign-in locally does not replace the local DAB simulation or establish cloud backend permissions; see the [authentication boundaries](authentication.md#three-distinct-boundaries). -Example reading owner-filtered file jobs from DAB directly with the local-only helper (after creating a job through the browser): +Example calling the readiness procedure on DAB directly with the local-only helper (replace `AppUser` and `/api/AppReady` with the values in `role-based-data.json`): ```powershell @' import { localPrincipal, localOrigin } from './dist/src/local.js'; +const role = 'AppUser'; const headers = { - 'x-ms-client-principal': localPrincipal('11111111-1111-4111-8111-111111111111'), + 'x-ms-client-principal': localPrincipal('11111111-1111-4111-8111-111111111111', 'access_as_user', role), + 'x-ms-api-role': role, }; -const rest = await fetch(`${localOrigin}/api/FileJob`, { headers }); +const rest = await fetch(`${localOrigin}/api/AppReady`, { headers }); if (!rest.ok) throw new Error(`REST failed: ${rest.status}`); console.log(await rest.json()); -const graph = await fetch(`${localOrigin}/graphql`, { - method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, - body: JSON.stringify({ query: '{ fileJobs { items { id status } } }' }), -}); -if (!graph.ok) throw new Error(`GraphQL failed: ${graph.status}`); -console.log(await graph.json()); '@ | node --input-type=module - ``` -Change the simulated object ID to exercise another user's view. Claims can be forged locally by design: never expose this endpoint remotely or deploy its generated configuration. +Change the role to exercise denial. Claims can be forged locally by design: never expose this endpoint remotely or deploy its generated configuration. -The Todo sample is not a default entity, browser feature or runtime dependency. See [application boundaries](../maintainers/application-boundary.md) for source/build/model, authenticated API and read-only database checks. Existing sample data is retained deliberately by schema publication and causes the database check to fail until a reviewed cleanup is explicitly approved. +The Todo sample is not a default entity, browser feature or runtime dependency. See [application boundaries](../maintainers/application-boundary.md) for source/build/model, authenticated API and read-only database checks. `npm run local -- app-check` inspects legacy sample objects without dropping data. Existing sample data is retained deliberately by schema publication and causes the database check to fail until a reviewed cleanup is explicitly approved. ## Lifecycle and secrets ```powershell npm run local -- stop npm run local -- data -npm run local -- services -npm run local -- stop-services ``` -`stop` removes only the project-labeled DAB container; `data` recreates it. `stop-services` removes the worker container and stops Azurite, retaining SQL/Blob/Queue data and the local network. Restart with `services`. To stop project-created SQL separately, use Docker's standard `docker stop sql-apps-sql`. Restart with `start-sql`, `data`, then `services`. Stopping/removing containers does not delete named data volumes. Never remove volumes unless you intentionally want to discard data. +`stop` removes only the project-labeled DAB container; `data` recreates it. To stop project-created SQL separately, use Docker's standard `docker stop sql-apps-sql`. Restart with `start-sql`, then `data`. Stopping/removing containers does not delete named data volumes. Never remove volumes unless you intentionally want to discard data. -Gitignored `.sql-apps` holds per-container SQL/storage/function credentials and generated local DAB configuration. Preserve credentials alongside their persistent volumes and protect them as secrets. Lost/mismatched credentials fail explicitly; the CLI does not silently reset an existing login or replace a known storage account key. Bootstrap secrets remain in Docker configuration, not repository files. Do not check in state files or share Docker inspections. +Gitignored `.sql-apps` holds per-container SQL credentials and generated local DAB configuration. Preserve credentials alongside their persistent volumes and protect them as secrets. Lost/mismatched credentials fail explicitly; the CLI does not silently reset an existing login. Bootstrap secrets remain in Docker configuration, not repository files. Do not check in state files or share Docker inspections. -Ownership labels prevent accidental replacement/removal of unrelated containers. Isolated lifecycle checks also verify workspace labels, expected image/provenance shape and loopback published ports before modifying containers; SQL/storage volumes and networks have workspace ownership labels. An unlabeled or mismatched container occupying a project name produces an actionable error rather than being deleted. Workspace Functions image tags include build-artifact fingerprints instead of sharing the global legacy image tag. +Ownership labels prevent accidental replacement/removal of unrelated containers. Isolated lifecycle checks also verify workspace labels, expected image/provenance shape and loopback published ports before modifying containers; SQL volumes and networks have workspace ownership labels. An unlabeled or mismatched container occupying a project name produces an actionable error rather than being deleted. ## What is and is not verified -Functions packaging has a clean-room regression that stages the actual Docker recipes, invokes the real compiler and checks shipped relative-module dependencies: - -```powershell -npm run build -node node_modules\typescript\lib\tsc.js -node --test dist\tests\functions-packaging.test.js -``` - -It needs installed dependencies but no running SQL or Docker services. After approval for image downloads/cache writes, validate both actual Functions images separately: - -```powershell -docker build --file functions\local.Dockerfile --tag sql-apps-functions:local-check . -docker build --file functions\Dockerfile --tag sql-apps-functions:production-check . -``` - -The compiler stages need the workspace-check script and declaration. The local runtime also needs the shared `.mjs` helpers and runtime workspace-check script; host compilation alone does not prove those image inputs are complete. Successful image builds do not prove Functions host execution or a completed browser-submitted job. These commands neither start services nor deploy or push an image. - -This is a usable **frontend/gateway/SQL/DAB/Blob/Queue/Functions learning workflow**, not a complete emulation of Azure. Real two-user Blob access, real Functions HTTP/queue triggers, SQL job ownership, retry/failure behavior and persisted results are tested locally. Offline worker startup is tested on a network with no public egress; the pinned Functions base contains its extension bundle. Azurite telemetry is disabled. First-time package/image/tool downloads still need internet, as do changed dependencies that are not already cached. +This is a usable **frontend/gateway/SQL/DAB learning workflow**, not a complete emulation of Azure. Role-authorized procedure access, least-privilege SQL grants and the readiness check are tested locally. First-time package/image/tool downloads need internet, as do changed dependencies that are not already cached. -Browser Entra sign-in can be tested through the explicit local opt-in, but cloud sign-in, managed identity, private endpoints, cloud Blob/Functions, Key Vault and cloud recovery still require Azure acceptance. Local SQL login/EasyAuth/storage secrets are intentionally separate from cloud Entra/managed-identity configuration. Azurite is not Azure Files or ADLS Gen2; its Table service is not part of this workflow. The SQL preview is not a production database or backup service; see the [current known limitations](https://microsoft.github.io/azure-sql-database-container/known-limitations.html). +Browser Entra sign-in can be tested through the explicit local opt-in, but cloud sign-in, managed identity, private endpoints and cloud recovery still require Azure acceptance. Local SQL login/EasyAuth secrets are intentionally separate from cloud Entra/managed-identity configuration. The SQL preview is not a production database or backup service; see the [current known limitations](https://microsoft.github.io/azure-sql-database-container/known-limitations.html). -No Azure free allowance is needed to start locally. The existing cloud template includes billed EP1/S0/private-endpoint resources and is **not** a free-tier template. A separate optional learning cloud deployment profile remains future work; do not deploy the current template expecting a zero bill. +No Azure free allowance is needed to start locally. The role-based-data cloud +deployment uses free-limit serverless SQL, scale-to-zero Container Apps and +capped logs, but ACR, one SQL private endpoint and managed network +infrastructure can still charge. Do not deploy expecting a guaranteed zero bill. diff --git a/docs/reference/monitor-growth.md b/docs/reference/monitor-growth.md index 85968f7..fba31d1 100644 --- a/docs/reference/monitor-growth.md +++ b/docs/reference/monitor-growth.md @@ -2,7 +2,7 @@ Use this workflow after deploying to **Azure SQL Database**: observe a real app action, investigate the limiting resource, make an approved change, and check the result. Start with [Costs and growth](../guides/costs.md) for the billing choices. -The SQL Apps cost/preflight commands are offline or deployment-readiness checks, not live usage monitoring. The public-demo deployment workflow remains incomplete; this page does not provide an alternative provisioning path. +SQL Apps deployment checks are readiness checks, not live usage monitoring; use the Azure Cost plugin for actual spend and forecasts. This page does not provide an alternative provisioning path. ## 1. Connect Database Hub for free monitoring @@ -40,7 +40,7 @@ Database performance percentages are not a billing meter. - In Cost Management, check actual and forecast costs for both the application resource group and the Container Apps managed infrastructure group. Include registry/network charges. - Configure budget alerts with reviewed recipients and thresholds if needed. Alerts are delayed notifications, not a hard spending cap. -Keep free-tier SQL pause-at-monthly-limit behavior unless the owner deliberately chooses another reviewed billing plan. Database Hub is not an automatic upgrade or billing-control mechanism. See the [cost reference](demo-cost.md) for allowance figures and billing-policy caveats. +Keep free-tier SQL pause-at-monthly-limit behavior unless the owner deliberately chooses another reviewed billing plan. Database Hub is not an automatic upgrade or billing-control mechanism. Use the Azure Cost plugin and [costs and growth](../guides/costs.md) for current allowances and billing-policy caveats. ## 3. Investigate a real slow action diff --git a/docs/reference/projects.md b/docs/reference/projects.md index 074bd90..48b299c 100644 --- a/docs/reference/projects.md +++ b/docs/reference/projects.md @@ -25,15 +25,17 @@ has this shape: "scaffoldVersion": 1, "tooling": { "package": "@microsoft/sql-apps", - "version": "0.1.2" + "version": "0.2.0" } } ``` -`starter` and `profile` must agree: - -- `data` uses `role-based-data`. -- `files-jobs` uses `foundation`. +`starter` must be `data` and `profile` must be `role-based-data`. The +`files-jobs` starter and its `foundation` profile were removed in 0.2.0; +manifests that still name them are rejected, so keep such a project on 0.1.x +tooling. New projects copy `.config`, `content`, `dab`, `docs`, `infra`, +`public`, `scripts`, `sql` and `src` and write `role-based-data.json`; they no +longer receive a `functions/` folder. The tooling version is an exact semantic version, not a range. Commands reject missing, malformed, oversized or linked manifests and stop searching at the @@ -79,6 +81,6 @@ Git history and `.sql-apps` state; it is not a worktree of the foundation. Project scaffolding must write the manifest and package metadata atomically, refuse an existing destination and clean up only the temporary directory it -created after a failure. The current foundation does not expose a public -one-command project generator, so documentation and plugins must not advertise -one until that workflow emits this package-based contract. +created after a failure. The foundation exposes project creation through +`npm run projects -- create ""`; generated projects use this package-based +contract and are independent of the foundation checkout. diff --git a/docs/reference/role-based-data.md b/docs/reference/role-based-data.md index 9443bbd..8b96e36 100644 --- a/docs/reference/role-based-data.md +++ b/docs/reference/role-based-data.md @@ -1,6 +1,8 @@ # Role-based data-only applications -Use the explicit `role-based-data` profile for an implemented domain application that needs SQL/DAB/browser access restricted to users assigned the required role, without files or background jobs. It is a runtime/deployment path, not an application generator. Keep the original foundation and unrelated databases unchanged; use the intended application checkout. +`role-based-data` is the only SQL Apps profile, locally and in the cloud. Use it for an implemented domain application that needs SQL/DAB/browser access restricted to users assigned the required role. File storage and background jobs are outside SQL Apps; add them with maintained Azure skills such as `azure-storage`, `azure-prepare` and `azure-deploy`. It is a runtime/deployment path, not an application generator. Keep the original foundation checkout and unrelated databases unchanged; use the intended application checkout. + +> **Cloud lifecycle:** The Azure commands in this reference remain supported for existing SQL Apps deployments. For new cloud deployments, use maintained Azure Skills for onboarding, infrastructure, predeployment validation, deployment, cost, and Azure diagnostics; preserve the SQL/DAB requirements and acceptance checks described here. This is application role-based access control: Entra authenticates users, the gateway requires the configured application role, and DAB enforces procedure permissions. It is not Azure resource-management RBAC and does not automatically provide row-level security (RLS). Implement row/owner restrictions separately where the application requires them. `AppUser` is an example role; choose a role appropriate to your application. @@ -8,10 +10,10 @@ This is application role-based access control: Entra authenticates users, the ga Keep `application.json` named for your app with `selectedExamples: []`. Implement its domain screens and SQL procedures. Copy `role-based-data.example.json` to `role-based-data.json` in that checkout and choose: -- `requiredRole`: a dedicated human role such as `AppUser`; reserved foundation/anonymous roles are rejected. +- `requiredRole`: a dedicated human role such as `AppUser`; reserved roles (`anonymous`, `authenticated`, `Function.Invoke`, `processor`) are rejected. - `readinessPath`: one `/api/` REST endpoint backed by an approved read-only procedure with no required request parameters. It must check the domain schema/access needed to operate, not substitute a success-shaped response for real readiness. -Configure `dab/dab-config.json` with explicit stored-procedure entities and role-authorized `execute` permissions. Autoentities, foundation FileJob entities and anonymous/processor permissions are rejected. Source objects use `schema.procedure` identifiers. A readiness entity looks like: +Configure `dab/dab-config.json` with explicit stored-procedure entities and role-authorized `execute` permissions. Autoentities, legacy `FileJob` entities and anonymous/processor permissions are rejected. Source objects use `schema.procedure` identifiers. A readiness entity looks like: ```json "AppReady": { @@ -24,7 +26,7 @@ Configure `dab/dab-config.json` with explicit stored-procedure entities and role Implement the procedure in the app's SQL project before startup. Other authorized procedures can expose approved GET/POST operations. DAB defaults procedures to POST, so explicitly enable GET on the read-only readiness procedure. Permission grants are generated only for the validated procedures (`EXECUTE` and object-scoped `VIEW DEFINITION` for DAB metadata); no schema-wide execute or database-owner grant is added. -For cloud delivery, preserve production `AzureAD` DAB authentication, `/api` REST routing, and JWT settings `@env('API_CLIENT_ID')` / `@env('ENTRA_ISSUER')`. The local launcher generates a separately saved AppService simulation configuration; never deploy that configuration. Hide excluded UI controls; the authorized gateway does not register file/job/function routes. +For cloud delivery, preserve production `AzureAD` DAB authentication, `/api` REST routing, and JWT settings `@env('API_CLIENT_ID')` / `@env('ENTRA_ISSUER')`. The local launcher generates a separately saved AppService simulation configuration; never deploy that configuration. The gateway serves only the browser app, authentication routes and the authorized `/api` and `/graphql` proxy. ## Local startup and acceptance @@ -35,10 +37,10 @@ Before build, `node scripts/setup-check.mjs --profile role-based-data` checks pr After an approved build and explicit workspace selection: ```powershell -npm run local -- role-based-app +npm run local -- app ``` -An optional second argument selects an explicitly approved existing SQL container. Startup runs SQL, schema publishing and DAB, then checks the actual readiness procedure before listening on the loopback gateway. It does not start Azurite or Functions or run foundation file/job acceptance. Port ownership checks include only selected services. It preserves data and reports the failed stage rather than resetting volumes. +`role-based-app` is an alias of `app`. An optional second argument selects an explicitly approved existing SQL container. Startup runs the `start-sql`, `init` (schema, procedure grants and read-only SQL acceptance) and `data` stages, then checks the actual readiness procedure before listening on the loopback gateway (`serve`). Port ownership checks include only the gateway, DAB and SQL ports. It preserves data and reports the failed stage rather than resetting volumes. After readiness passes, open the reported workspace origin and select **Open local preview**. Product screens use account/access states, not persona selectors. The server chooses the default simulated identity. For authorization testing, open the reported local-only developer page in the same tab; Development Alice is labeled as having the required role and Development Bob as lacking it. Switch there and return to the application; switching revokes the old server-issued session. @@ -47,22 +49,16 @@ Caller role/principal headers cannot grant access. The gateway forwards the conf Verify an agreed browser action, SQL persistence/reload, invalid input and applicable owner rules, plus denial for an identity without the required role. A verified non-role account receives HTTP 200 identity/access information from `/auth/me`, a clear access-denied state with sign-out still available, and HTTP 403 on domain operations before DAB is called. Denial must not erase the account or invite a product persona choice. These checks are separate from startup readiness. Ctrl+C stops the gateway; SQL/DAB data remain. Resume with: ```powershell -npm run local -- role-based-serve +npm run local -- serve ``` -Use the existing approved `stop` command to stop only the owned DAB container when needed; never use `stop-services` for this profile or delete SQL volumes to resume. +`role-based-serve` is an alias of `serve`. Use the existing approved `stop` command to stop only the owned DAB container when needed; never delete SQL volumes to resume. ## Profile-specific Azure preparation -Copy `azure-role-based.example.json` to an application-specific configuration, fill the exact tenant/subscription/target and immutable version image choices, and retain `profile`, `requiredRole` and `readinessPath` consistent with the local app. No Functions image is accepted. Placeholder identifiers in the example are not valid deployment targets. - -Review costs offline: - -```powershell -npm run azure -- role-based-cost azure-role-based-cost.example.json -``` +Copy `azure-role-based.example.json` to an application-specific configuration, fill the exact tenant/subscription/target and immutable version image choices, and retain `profile`, `requiredRole` and `readinessPath` consistent with the local app. No Functions image is accepted (a legacy `functionsImage` must be empty). Placeholder identifiers in the example are not valid deployment targets. -The zero-spend example exits **2** because this path uses paid provisioned S0 SQL and private infrastructure. To review paid costs, explicitly choose `review-paid-costs` and acknowledge fixed charges in a separate cost configuration. The report is not a quote, eligibility check, spending cap or deployment approval. It describes the actual profile: two Consumption apps with minimum one replica each, S0 SQL, the configured registry, SQL private endpoint/DNS, managed networking and Log Analytics. Registry tier and regional prices require review. `demo-cost` is not this profile's cost model. +Review costs with the Azure Cost plugin before approving paid steps. The profile uses eligible free-limit serverless SQL with `AutoPause`, two scale-to-zero Consumption apps, Local backup redundancy and a 0.1 GB/day Log Analytics cap, but ACR Basic, one SQL private endpoint and custom Container Apps network infrastructure can still carry fixed charges. Free SQL eligibility and shared Container Apps allowances must be verified for the target subscription. An estimate is not a quote, eligibility check, spending cap or deployment approval. With separate target/read-only authorization, validate the app/configuration and review what-if. No command silently selects discovered older resources; treat them as collisions unless reuse is explicitly requested. @@ -74,7 +70,7 @@ After explicit approval for each relevant cloud-write scope: npm run azure -- identity azure-role-based.json ``` -This creates a new single-tenant registration/service principal with delegated `access_as_user` and the configured human application role (allowed member type `User`), not `Function.Invoke`. A configuration from `npm run azure -- init ... --profile role-based-data` receives the printed identifier automatically, and `deploy-all` runs identity, artifacts, provision, deploy and status as one reviewed approval ([deployment reference](deployment.md#shortest-path)); otherwise set `apiClientId` to its printed identifier. Existing registrations need an enabled equivalent application role; creation does not patch unrelated registrations. +This creates a new single-tenant registration/service principal with delegated `access_as_user` and the configured human application role (allowed member type `User`). A configuration from `npm run azure -- init ...` receives the printed identifier automatically. `deploy-all` runs the reviewed ordinary-operator stages, stops after provisioning for the separate administrator RBAC handoff, and resumes afterward ([deployment reference](deployment.md#shortest-path)); otherwise set `apiClientId` to its printed identifier. Existing registrations need an enabled equivalent application role; creation does not patch unrelated registrations. Assign only approved authorized users/groups by object ID: @@ -84,22 +80,29 @@ npm run azure -- role-based-assign azure-role-based.json node scripts\setup.mjs run --profile role-based-data --workspace 30000 --json ``` `approval` proposes a free port block when none is selected and lists every -scope it covers: restore, build, the workspace, image downloads and SQL startup, -schema, services, synthetic checks for the foundation, and a SQL terms line -(startup passes `ACCEPT_EULA=Y` for the +scope it covers for the startup operation `app`: restore, build, the workspace, +image downloads and SQL startup (`sql-start`), schema, services, `synthetic` +(the read-only SQL acceptance check on the least-privilege app user during +`init`), and a SQL terms line (startup passes `ACCEPT_EULA=Y` for the [container terms](https://aka.ms/azuresqldb-container), no other license). It -also lists what stays separate: tool installs, stop/recovery, maintenance and -Azure. `approve` re-plans and refuses a stale id, so a changed plan must be shown +also lists what stays separate: tool installs, stop/recovery and Azure. +`approve` re-plans and refuses a stale id, so a changed plan must be shown again. `run` and `resume` themselves never record approvals. Existing matching grants are reused; see [Remember local permissions](local-consent.md). -For `role-based-data`, this approval can be reviewed and recorded before +This approval can be reviewed and recorded before `role-based-data.json`, DAB entities and procedures are complete. The coordinator may then restore dependencies, build and initialize the workspace, but it keeps the launch stage blocked with the specific configuration diagnostic until those @@ -160,12 +159,12 @@ domain inputs are valid. After a stop or a new session, resume without another approval: ```powershell -node scripts\setup.mjs resume --profile role-based-data --json +node scripts\setup.mjs resume --json ``` For agreed external SQL reuse, pass the same `--container ` to `plan`, `run` and `resume`. Review and grant the individual -`consent-plan role-based-app ` (or foundation `app`) target through +`consent-plan app ` target through [local consent](local-consent.md); the setup approval bundle covers only workspace-owned SQL. Gateway identity checks match the selected container, not the workspace's default container. @@ -193,14 +192,14 @@ the gateway. An unready match remains a nonzero launch blocker; recovery is recognized by the next probe without restarting the gateway. `workflowVerified` still remains false. -New startup requires all selected-profile dependencies before listening and -publishes a launch URL only after actual HTTP identity/readiness confirmation. +New startup runs the stages start-sql -> init -> data -> serve and requires the +configured readiness procedure before listening; it publishes a launch URL only +after actual HTTP identity/readiness confirmation. Gateway-only resume makes three read-only attempts with one-second pauses and a four-second shared probe budget per attempt; it does not start or repair missing services. A failed initial attempt sequence closes only the new gateway. A later dependency outage keeps the established gateway live with HTTP 503 readiness and -HTTP 200 liveness. Worker failure alone does not prevent durable submissions when -the data and storage dependencies still work. +HTTP 200 liveness. Regression tests exercise real temporary child processes and loopback HTTP readiness, early exits, identity conflicts and timeout cleanup. These fixtures diff --git a/examples/todo/README.md b/examples/todo/README.md index 1a07b19..bd969fc 100644 --- a/examples/todo/README.md +++ b/examples/todo/README.md @@ -59,7 +59,7 @@ npm run local -- selected-test "" The opt-in suite lives in `demo/smoke.mjs`, outside default delivery. It measures actual two-session ownership, the 200/201 UTF-16 code-unit boundary with ASCII and supplementary Unicode titles, concurrent 50-item quota and 30/31-mutation rate limits, SQL expiry, bounded cleanup, execute-only permissions, pooled-context spoof rejection, and end-session cascade deletion. It advances clocks only for its randomly created synthetic sessions and removes those sessions/rows on success or failure. It does not restart services. Run it in an approved disposable workspace without concurrent activity for deterministic cleanup-count evidence. -Before relying on the example, try browser create/update/completion/reload/delete and check persistence after restarting your own stack. The public-demo artifact is buildable, but the guided Azure provisioning/handoff path is still in development; no cloud deployment is implied by local readiness. +Before relying on the example, try browser create/update/completion/reload/delete and check persistence after restarting your own stack. The public-demo artifact is buildable; deploy it through the maintained Azure Skills (prepare, validate, deploy) and keep synthetic data only. No cloud deployment is implied by local readiness. The selected gateway validates successful procedure records, including the requested item's identity for update/delete. An empty, incomplete or mismatched deletion result fails as HTTP502 rather than returning a false-success HTTP204. diff --git a/examples/todo/smoke.ts b/examples/todo/smoke.ts index 7c39f95..b12c763 100644 --- a/examples/todo/smoke.ts +++ b/examples/todo/smoke.ts @@ -10,7 +10,7 @@ const graphSchema = z.object({ }); export async function smoke( - origin: string, userToken: string, otherUserToken: string, fetcher: typeof fetch = fetch, functionsUrl?: string, + origin: string, userToken: string, otherUserToken: string, fetcher: typeof fetch = fetch, ): Promise { const user = new SqlAppsClient({ baseUrl: origin, getAccessToken: async () => userToken, fetch: fetcher }); const other = new SqlAppsClient({ baseUrl: origin, getAccessToken: async () => otherUserToken, fetch: fetcher }); @@ -20,9 +20,7 @@ export async function smoke( throw new Error('Two different verified users in the configured tenant are required'); } const id = randomUUID(); - const filename = `acceptance-${id}.txt`; let created = false; - let uploaded = false; const failures: unknown[] = []; try { await user.createTodo(`Acceptance ${id}`, id); @@ -33,12 +31,6 @@ export async function smoke( if (!(await user.listTodos()).some(todo => todo.id === id && todo.completed)) throw new Error('Todo update did not persist'); await expectDenied(() => other.updateTodo(id, { title: 'unauthorized' })); await expectDenied(() => other.deleteTodo(id)); - await user.upload(filename, Buffer.from('private acceptance data')); - uploaded = true; - const downloaded = Buffer.from(await user.download(filename)).toString(); - if (downloaded !== 'private acceptance data') throw new Error('File roundtrip payload mismatch'); - await expectDenied(() => other.download(filename)); - await expectDenied(() => other.deleteFile(filename)); const graph = await user.request('/graphql', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query: '{ todos { items { id title completed } } }' }), @@ -54,27 +46,11 @@ export async function smoke( if (otherGraph.errors || !otherGraph.data || otherGraph.data.todos.items.some(todo => todo.id.toLowerCase() === id)) { throw new Error('GraphQL cross-user ownership check failed'); } - const result = z.object({ result: z.object({ - user: identitySchema, input: z.object({ acceptance: z.string() }), - }) }).parse(await user.echo({ acceptance: id })); - if (result.result.user.oid !== who.oid || result.result.user.tenantId !== who.tenantId || result.result.input.acceptance !== id) { - throw new Error('Function user context or payload mismatch'); - } - if (functionsUrl) { - const direct = await fetcher(new URL('/api/echo', functionsUrl), { - method: 'POST', headers: { authorization: `Bearer ${userToken}`, 'content-type': 'application/json' }, - body: '{}', signal: AbortSignal.timeout(10_000), - }); - if (direct.status !== 401) throw new Error('Direct function user-token access was not rejected'); - } const anonymous = await fetcher(new URL('/api/Todo', origin), { signal: AbortSignal.timeout(5_000) }); if (anonymous.status !== 401) throw new Error('Anonymous API access was not rejected'); } catch (error) { failures.push(error); } finally { - if (uploaded) { - try { await user.deleteFile(filename); } catch (error) { failures.push(error); } - } if (created) { try { await user.deleteTodo(id); } catch (error) { failures.push(error); } } diff --git a/examples/todo/tests/client.test.ts b/examples/todo/tests/client.test.ts index 6bc8c70..bc62e06 100644 --- a/examples/todo/tests/client.test.ts +++ b/examples/todo/tests/client.test.ts @@ -128,61 +128,8 @@ test('client invokes the default fetch without binding it to the client instance assert.deepEqual(await client.listTodos(), []); } finally { globalThis.fetch = original; } }); -test('file and processing client methods validate output and preserve exact request shapes', async () => { - const id = '11111111-1111-4111-8111-111111111111'; - const calls: { path: string; method: string; body: unknown }[] = []; - const client = new TodoExampleClient({ - baseUrl: 'https://app.example', getAccessToken: async () => 'token', - fetch: async (url, options) => { - const path = new URL(String(url)).pathname; - const method = options?.method ?? 'GET'; - calls.push({ path, method, body: options?.body }); - if (method === 'DELETE') return new Response(null, { status: 204 }); - if (method === 'POST') return Response.json({ id }, { status: 202 }); - if (path === '/storage') return Response.json({ files: [{ name: 'note.txt', size: 12 }] }); - return Response.json({ jobs: [{ id: id.toUpperCase(), filename: 'note.txt', status: 'queued', - sha256: null, byte_count: null, line_count: null, error: null }] }); - }, - }); - assert.deepEqual(await client.listFiles(), { files: [{ name: 'note.txt', size: 12 }] }); - assert.equal((await client.listJobs())[0]?.id, id); - assert.equal(await client.processFile('note.txt'), id); - await client.deleteJob(id); - assert.deepEqual(calls.slice(2), [ - { path: '/jobs', method: 'POST', body: '{"name":"note.txt"}' }, - { path: `/jobs/${id}`, method: 'DELETE', body: undefined }, - ]); - for (const value of [{ files: [{ name: 'note.txt', size: -1 }] }, { jobs: [{ id: 'invalid', status: 'completed' }] }]) { - const invalid = new TodoExampleClient({ baseUrl: 'https://app.example', getAccessToken: async () => 'token', - fetch: async () => Response.json(value) }); - await assert.rejects('files' in value ? invalid.listFiles() : invalid.listJobs()); - } -}); -test('file client loads bounded storage pages only when requested', async () => { - const requests: string[] = []; - const client = new TodoExampleClient({ - baseUrl: 'https://app.example', getAccessToken: async () => 'token', - fetch: async url => { - const request = new URL(String(url)); - requests.push(request.pathname + request.search); - return request.search - ? Response.json({ files: [{ name: 'second.txt', size: 2 }] }) - : Response.json({ files: [{ name: 'first.txt', size: 1 }], continuationToken: 'next page' }); - }, - }); - const first = await client.listFiles(); - assert.deepEqual(first, { - files: [{ name: 'first.txt', size: 1 }], continuationToken: 'next page', - }); - assert.deepEqual(requests, ['/storage']); - assert.deepEqual(await client.listFiles(first.continuationToken), { - files: [{ name: 'second.txt', size: 2 }], - }); - assert.deepEqual(requests, ['/storage', '/storage?continuationToken=next+page']); -}); test('acceptance succeeds with isolated dependencies and cleans up its fixtures', async () => { const rows = new Map(); - const files = new Map(); const fetcher: typeof fetch = async (url, init) => { const path = new URL(String(url)).pathname; const user = new Headers(init?.headers).get('authorization'); @@ -203,23 +150,9 @@ test('acceptance succeeds with isolated dependencies and cleans up its fixtures' else if (init?.method === 'PATCH') row.completed = true; return response({}); } - if (path.startsWith('/storage/')) { - const key = `${user}:${path}`; - if (init?.method === 'PUT') { - files.set(key, Buffer.from(init.body as ArrayBuffer).toString()); - return new Response(null, { status: 204 }); - } - if (!files.has(key)) return response({}, 404); - if (init?.method === 'DELETE') { files.delete(key); return new Response(null, { status: 204 }); } - return new Response(files.get(key)); - } if (path === '/graphql') return response({ data: { todos: { items: [...rows.values()].filter(row => row.owner === user) } } }); - if (path === '/functions/echo') return response({ - result: { user: { oid: user, tenantId: 'tenant' }, input: JSON.parse(String(init?.body)) }, - }); throw new Error(`Unexpected acceptance path: ${path}`); }; await smoke('https://app.example', 'a', 'b', fetcher); assert.equal(rows.size, 0); - assert.equal(files.size, 0); }); diff --git a/functions/Dockerfile b/functions/Dockerfile deleted file mode 100644 index 42bd99b..0000000 --- a/functions/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -FROM node:22.22.2-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS build -WORKDIR /app -COPY package*.json ./ -RUN npm ci -COPY tsconfig*.json ./ -COPY scripts/workspace-check.mjs scripts/workspace-check.d.mts ./scripts/ -COPY src ./src -COPY functions ./functions -RUN npx tsc --project tsconfig.runtime.json && npm prune --omit=dev - -FROM mcr.microsoft.com/azure-functions/node:4-node22@sha256:4ba7ae108e9e4cb1e806311f1e83bf784543b99d91a2cdbc8aa72bc092153334 -ENV AzureWebJobsScriptRoot=/home/site/wwwroot AzureFunctionsJobHost__Logging__Console__IsEnabled=true -WORKDIR /home/site/wwwroot -COPY --from=build /app/node_modules ./node_modules -COPY --from=build /app/dist/functions ./dist/functions -COPY functions/package.json functions/host.json ./ diff --git a/functions/host.json b/functions/host.json deleted file mode 100644 index 157b196..0000000 --- a/functions/host.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": "2.0", - "extensions": { "http": { "routePrefix": "api" } }, - "logging": { "logLevel": { "default": "Warning", "Function": "Information" } } -} diff --git a/functions/local-host.json b/functions/local-host.json deleted file mode 100644 index 830d551..0000000 --- a/functions/local-host.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": "2.0", - "extensionBundle": { "id": "Microsoft.Azure.Functions.ExtensionBundle", "version": "[4.0.0, 5.0.0)" }, - "extensions": { "queues": { "batchSize": 1, "maxDequeueCount": 5, "visibilityTimeout": "00:00:05", "maxPollingInterval": "00:00:02" } }, - "logging": { "logLevel": { "default": "Warning", "Function": "Information" } } -} diff --git a/functions/local-package.json b/functions/local-package.json deleted file mode 100644 index 2434232..0000000 --- a/functions/local-package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "sql-apps-local-functions", - "version": "0.1.2", - "private": true, - "type": "module", - "main": "dist/functions/src/local-index.js" -} diff --git a/functions/local.Dockerfile b/functions/local.Dockerfile deleted file mode 100644 index e3a0aae..0000000 --- a/functions/local.Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM mcr.microsoft.com/azure-functions/node:4-node22@sha256:4ba7ae108e9e4cb1e806311f1e83bf784543b99d91a2cdbc8aa72bc092153334 -ENV AzureWebJobsScriptRoot=/home/site/wwwroot AzureFunctionsJobHost__Logging__Console__IsEnabled=true -WORKDIR /home/site/wwwroot -COPY package.json ./ -RUN npm install --omit=dev --ignore-scripts -COPY dist/src ./dist/src -COPY dist/functions ./dist/functions -COPY assets/functions/local-package.json ./package.json -COPY assets/functions/local-host.json ./host.json diff --git a/functions/package.json b/functions/package.json deleted file mode 100644 index df0c731..0000000 --- a/functions/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "sql-apps-functions", - "version": "0.1.2", - "private": true, - "type": "module", - "main": "dist/functions/src/index.js", - "dependencies": { - "@azure/functions": "4.16.5", - "@azure/identity": "4.13.3", - "@azure/keyvault-secrets": "4.11.2", - "jose": "6.2.12" - } -} diff --git a/functions/src/authorization.ts b/functions/src/authorization.ts deleted file mode 100644 index 80312f6..0000000 --- a/functions/src/authorization.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { jwtVerify, createRemoteJWKSet, type JWTVerifyGetKey } from 'jose'; - -export function serviceVerifier( - tenantId: string, audience: string, gatewayPrincipalId: string, - getKey: JWTVerifyGetKey = createRemoteJWKSet( - new URL(`https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`), - ), -) { - return async (token: string) => { - const { payload } = await jwtVerify(token, getKey, { - issuer: `https://login.microsoftonline.com/${tenantId}/v2.0`, - audience, algorithms: ['RS256'], requiredClaims: ['exp', 'iat', 'oid', 'tid'], - }); - if (payload.tid !== tenantId || payload.oid !== gatewayPrincipalId || payload.scp !== undefined || - !Array.isArray(payload.roles) || !payload.roles.includes('Function.Invoke')) { - throw new Error('Gateway application identity required'); - } - }; -} diff --git a/functions/src/file-analysis.ts b/functions/src/file-analysis.ts deleted file mode 100644 index d8a893f..0000000 --- a/functions/src/file-analysis.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createHash } from 'node:crypto'; - -export function analyzeFile(bytes: Buffer) { - if (bytes.length > 4 * 1024 * 1024) throw new Error('File exceeds the 4 MiB processing limit'); - return { - sha256: createHash('sha256').update(bytes).digest('hex'), byte_count: bytes.length, - line_count: bytes.length === 0 ? 0 : bytes.toString('utf8').split(/\r\n|\r|\n/).length, - }; -} diff --git a/functions/src/index.ts b/functions/src/index.ts deleted file mode 100644 index 6c9f779..0000000 --- a/functions/src/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { app } from '@azure/functions'; -import { serviceVerifier } from './authorization.js'; - -function required(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`Missing required setting: ${name}`); - return value; -} -const verify = serviceVerifier(required('AZURE_TENANT_ID'), required('API_CLIENT_ID'), required('GATEWAY_PRINCIPAL_ID')); -app.http('echo', { - methods: ['POST'], - authLevel: 'anonymous', - handler: async (request, context) => { - const token = /^Bearer ([^\s]+)$/i.exec(request.headers.get('authorization') ?? '')?.[1]; - if (!token) return { status: 401, jsonBody: { error: 'Gateway application identity required' } }; - try { - await verify(token); - } catch (error) { - context.warn('Function access rejected', error instanceof Error ? error.name : 'UnknownError'); - return { status: 401, jsonBody: { error: 'Gateway application identity required' } }; - } - let body: unknown; - try { body = await request.json(); } - catch { return { status: 400, jsonBody: { error: 'JSON body required' } }; } - return { status: 200, jsonBody: { result: body } }; - }, -}); diff --git a/functions/src/local-index.ts b/functions/src/local-index.ts deleted file mode 100644 index fa5da59..0000000 --- a/functions/src/local-index.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { app } from '@azure/functions'; -import { BlobServiceClient } from '@azure/storage-blob'; -import { timingSafeEqual } from 'node:crypto'; -import { z } from 'zod'; -import { jobMessageSchema } from '../../src/file-jobs.js'; -import { analyzeFile } from './file-analysis.js'; -import { localJobData } from '../../src/local-job-data.js'; -import { withJobSnapshotLease } from '../../src/local-job-snapshots.js'; -import { localSettingsSchema } from '../../src/local-settings.js'; -import { localTracing } from '../../src/local-tracing.js'; -import { maintainLocalJobs } from '../../src/local-maintenance.js'; -import type { TraceParent } from '../../src/trace-contract.js'; - -if (process.env.NODE_ENV === 'production' || process.env.SQL_APPS_LOCAL_FUNCTIONS !== 'true') { - throw new Error('Local Functions entry point requires explicit development mode outside production'); -} -const secret = process.env.LOCAL_FUNCTION_SECRET; -const connection = process.env.AzureWebJobsStorage; -const dataOrigin = process.env.LOCAL_DAB_URL; -if (!secret || secret.length < 32 || !connection || !dataOrigin) throw new Error('Local Functions settings are incomplete'); -const expected = Buffer.from(secret); -const blobs = BlobServiceClient.fromConnectionString(connection).getContainerClient('files'); -const jobs = localJobData(dataOrigin); -const settings = localSettingsSchema.parse(JSON.parse(process.env.LOCAL_SETTINGS ?? '{}')); -const telemetry = settings.tracing ? localTracing(connection) : undefined; - -app.http('echo', { - methods: ['POST'], authLevel: 'anonymous', - handler: async (request, context) => { - const token = /^Bearer ([^\s]+)$/.exec(request.headers.get('authorization') ?? '')?.[1] ?? ''; - const provided = Buffer.from(token); - if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) { - context.warn('Local function invocation rejected'); - return { status: 401, jsonBody: { error: 'Local gateway credential required' } }; - } - let body: unknown; - try { body = await request.json(); } - catch { return { status: 400, jsonBody: { error: 'JSON body required' } }; } - const parsed = z.object({ user: z.object({ oid: z.string().uuid(), tenantId: z.string().uuid() }), input: z.unknown() }).safeParse(body); - if (!parsed.success) return { status: 400, jsonBody: { error: 'Verified user context required' } }; - return { jsonBody: { result: parsed.data } }; - }, -}); - -app.storageQueue('analyzeFile', { - queueName: 'file-jobs', connection: 'AzureWebJobsStorage', - handler: async (message: unknown, context) => { - const job = jobMessageSchema.parse(message); - if (job.key !== `${job.tenantId}/${job.oid}/jobs/${job.id}`) throw new Error('Job snapshot ownership mismatch'); - const initial = await jobs.get(job.oid, job.id); - if (!initial || initial.status === 'completed' || initial.status === 'failed') return; - await withJobSnapshotLease(blobs.getBlockBlobClient(job.key), async signal => { - const row = await jobs.get(job.oid, job.id, signal); - if (!row || row.status === 'completed' || row.status === 'failed') return; - const identity = { oid: job.oid, tenantId: job.tenantId }; - const process = async (parent?: TraceParent) => { - const step = async (name: string, work: () => Promise) => telemetry - ? telemetry.operation(identity, name, async (_trace, span) => { span.setAttribute('job.id', job.id); return work(); }, parent) - : work(); - await step('sql.job.processing', () => jobs.update(job.oid, job.id, - { status: 'processing', error: null }, signal)); - try { - const bytes = await step('blob.snapshot.read', () => - blobs.getBlockBlobClient(job.key).downloadToBuffer(0, undefined, { abortSignal: signal })); - await step('sql.job.complete', () => jobs.update(job.oid, job.id, - { status: 'completed', ...analyzeFile(bytes), error: null }, signal)); - } catch (error) { - context.error('File processing failed', { jobId: job.id, errorType: error instanceof Error ? error.name : 'UnknownError' }); - if (Number(context.triggerMetadata?.dequeueCount) >= 5) { - await jobs.update(job.oid, job.id, - { status: 'failed', error: 'Processing failed after five attempts; check worker logs' }, signal); - } - throw error; - } - }; - if (telemetry) await telemetry.operation(identity, 'queue.blob.analyze.sql.complete', - async (trace, span) => { span.setAttribute('job.id', job.id); await process(trace); }, job.trace); - else await process(); - }); - }, -}); - -app.timer('maintainFileJobs', { - schedule: '0 * * * * *', runOnStartup: false, useMonitor: true, - handler: async (_timer, context) => { - const traces = BlobServiceClient.fromConnectionString(connection).getContainerClient('traces'); - const result = await maintainLocalJobs(jobs, blobs, traces, settings); - context.log('Scheduled local job maintenance completed', result); - }, -}); - -app.storageQueue('failedFileJob', { - queueName: 'file-jobs-poison', connection: 'AzureWebJobsStorage', - handler: async (message: unknown, context) => { - const job = jobMessageSchema.parse(message); - if (job.key !== `${job.tenantId}/${job.oid}/jobs/${job.id}`) throw new Error('Job snapshot ownership mismatch'); - const row = await jobs.get(job.oid, job.id); - if (row && row.status !== 'completed') { - await jobs.update(job.oid, job.id, { status: 'failed', error: 'Queue delivery exhausted; check worker logs and submit a new job' }); - } - context.error('File job moved to poison queue', { jobId: job.id }); - }, -}); diff --git a/functions/src/secrets.ts b/functions/src/secrets.ts deleted file mode 100644 index c9c903e..0000000 --- a/functions/src/secrets.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { DefaultAzureCredential } from '@azure/identity'; -import { SecretClient } from '@azure/keyvault-secrets'; - -export function functionSecrets(vaultUrl: string, clientId: string) { - const client = new SecretClient(vaultUrl, new DefaultAzureCredential({ managedIdentityClientId: clientId })); - return { - async get(name: string): Promise { - if (!/^[a-zA-Z0-9-]{1,127}$/.test(name)) throw new Error('Invalid secret name'); - const secret = await client.getSecret(name); - if (secret.value === undefined) throw new Error(`Secret has no value: ${name}`); - return secret.value; - }, - }; -} diff --git a/gemini-extension.json b/gemini-extension.json index bed6f00..3819b4e 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,5 +1,5 @@ { "name": "sql-apps", - "version": "0.1.2", - "description": "Build and deploy this independent Azure-native TypeScript stack." + "version": "0.2.0", + "description": "Build and validate Azure SQL Database and Data API Builder applications; use maintained Azure skills for the general cloud lifecycle." } diff --git a/infra/demo-runtime.bicep b/infra/demo-runtime.bicep deleted file mode 100644 index 795c1c2..0000000 --- a/infra/demo-runtime.bicep +++ /dev/null @@ -1,147 +0,0 @@ -targetScope = 'resourceGroup' - -@description('Explicit Container App name and hostname prefix. Use a sqlapps-prefixed name for the SQL Apps starter; this must match the reviewed deployment target.') -param applicationName string -param selectedApplication string -param deploymentId string -param location string -param tags object -param environmentName string -param runtimeIdentityName string -param registryName string -param sqlServerName string -param databaseName string -@allowed([ - 'starter' - 'hardened' -]) -param securityPosture string -@description('Explicit supported Azure Static Web Apps resource location. This can differ from the Container Apps backend region.') -param staticWebAppLocation string -@minLength(3) -@maxLength(40) -param staticWebAppName string -@description('Approved selected gateway image pinned to its immutable sha256 digest.') -param gatewayImage string -@description('Approved selected DAB configuration image pinned to its immutable sha256 digest.') -param dabImage string - -var ownedTags = union(tags, { - 'sql-apps-deployment': deploymentId - 'sql-apps-application': selectedApplication - 'sql-apps-profile': 'public-demo' -}) - -resource environment 'Microsoft.App/managedEnvironments@2024-03-01' existing = { - name: environmentName -} -resource runtimeIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { - name: runtimeIdentityName -} -resource registry 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { - name: registryName -} -resource sql 'Microsoft.Sql/servers@2023-08-01' existing = { - name: sqlServerName -} - -var starterRegistry = securityPosture == 'starter' -var registryCredentials = starterRegistry ? registry.listCredentials() : null - -resource staticSite 'Microsoft.Web/staticSites@2023-12-01' = { - name: staticWebAppName - location: staticWebAppLocation - tags: ownedTags - sku: { - name: 'Standard' - tier: 'Standard' - } - properties: { - allowConfigFileUpdates: true - stagingEnvironmentPolicy: 'Disabled' - } -} - -var publicOrigin = 'https://${staticSite.properties.defaultHostname}' - -resource app 'Microsoft.App/containerApps@2024-03-01' = { - name: applicationName - location: location - tags: ownedTags - identity: { type: 'UserAssigned', userAssignedIdentities: { '${runtimeIdentity.id}': {} } } - properties: { - managedEnvironmentId: environment.id - workloadProfileName: 'Consumption' - configuration: { - activeRevisionsMode: 'Single' - ingress: { external: true, targetPort: 8080, transport: 'http', allowInsecure: false } - secrets: starterRegistry ? [ - { - name: 'registry-password' - value: registryCredentials!.passwords[0].value - } - ] : [] - registries: starterRegistry ? [ - { - server: registry.properties.loginServer - username: registryCredentials!.username - passwordSecretRef: 'registry-password' - } - ] : [ - { - server: registry.properties.loginServer - identity: runtimeIdentity.id - } - ] - } - template: { - scale: { - minReplicas: 0 - maxReplicas: 1 - rules: [{ name: 'http', http: { metadata: { concurrentRequests: '10' } } }] - } - containers: [ - { - name: 'gateway' - image: gatewayImage - resources: { cpu: json('0.25'), memory: '0.5Gi' } - env: [ - { name: 'NODE_ENV', value: 'production' } - { name: 'PORT', value: '8080' } - { name: 'SQL_APPS_PROFILE', value: 'public-demo' } - { name: 'PUBLIC_ORIGIN', value: publicOrigin } - { name: 'DAB_URL', value: 'http://127.0.0.1:5000' } - ] - probes: [ - { type: 'Liveness', httpGet: { path: '/health/live', port: 8080 }, initialDelaySeconds: 30, periodSeconds: 30 } - { type: 'Readiness', httpGet: { path: '/health/ready', port: 8080 }, initialDelaySeconds: 10, periodSeconds: 10 } - { type: 'Startup', httpGet: { path: '/health/live', port: 8080 }, periodSeconds: 5, failureThreshold: 60 } - ] - } - { - name: 'data' - image: dabImage - resources: { cpu: json('0.5'), memory: '1Gi' } - env: [ - { name: 'ASPNETCORE_URLS', value: 'http://127.0.0.1:5000' } - { name: 'SQL_CONNECTION_STRING', value: 'Server=tcp:${sql.properties.fullyQualifiedDomainName},1433;Database=${databaseName};Authentication=Active Directory Managed Identity;User Id=${runtimeIdentity.properties.clientId};Encrypt=True;TrustServerCertificate=False;' } - ] - } - ] - } - } -} - -resource linkedBackend 'Microsoft.Web/staticSites/linkedBackends@2023-12-01' = { - parent: staticSite - name: 'linkedBackend' - properties: { - backendResourceId: app.id - region: location - } -} - -output applicationId string = app.id -output publicUrl string = publicOrigin -output backendUrl string = 'https://${app.properties.configuration.ingress.fqdn}' -output staticWebAppName string = staticSite.name diff --git a/infra/demo.bicep b/infra/demo.bicep deleted file mode 100644 index 16f4bbf..0000000 --- a/infra/demo.bicep +++ /dev/null @@ -1,184 +0,0 @@ -targetScope = 'resourceGroup' - -@minLength(3) -@maxLength(16) -@description('Deployment prefix. The SQL Apps starter uses sqlapps; changing an existing prefix selects different resources.') -param name string = 'sqlapps' -param selectedApplication string -param deploymentId string -param location string -param tenantId string -param tags object -@minLength(5) -@maxLength(50) -param registryName string -param sqlAdminObjectId string -param sqlAdminName string -@description('Explicit policy for a new database. free-paused uses the free tier and never enables paid continuation; paid-reviewed requires separate cost approval. No existing-database conversion is authorized.') -@allowed(['free-paused', 'paid-reviewed']) -param sqlBillingMode string -@description('Required reviewed sizing contract. Used for paid-reviewed; free-paused uses its fixed General Purpose serverless free-tier SKU instead.') -param sqlSku object -@minValue(104857600) -param sqlMaxSizeBytes int -@allowed(['Local', 'Zone', 'Geo', 'GeoZone']) -param sqlBackupRedundancy string -@description('Registry credential posture. starter enables the registry admin user because the starter runtime pulls with it; hardened keeps the admin user disabled for identity-based pulls.') -@allowed([ - 'starter' - 'hardened' -]) -param securityPosture string -@description('Demo log retention in days for the environment workspace. The workspace stays inside the free daily ingestion allowance.') -@minValue(30) -@maxValue(90) -param logRetentionDays int = 30 - -var prefix = '${name}-${substring(deploymentId, 0, 8)}' -var suffix = uniqueString(resourceGroup().id, deploymentId) -var ownedTags = union(tags, { - 'sql-apps-deployment': deploymentId - 'sql-apps-application': selectedApplication - 'sql-apps-profile': 'public-demo' -}) -var freeSql = sqlBillingMode == 'free-paused' -var freeSqlSku = { name: 'GP_S_Gen5_2', tier: 'GeneralPurpose', family: 'Gen5', capacity: 2 } -var starterRegistry = securityPosture == 'starter' - -resource runtimeIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { - name: '${prefix}-runtime' - location: location - tags: ownedTags -} - -resource registry 'Microsoft.ContainerRegistry/registries@2025-04-01' = { - name: registryName - location: location - tags: ownedTags - sku: { name: 'Basic' } - properties: { - adminUserEnabled: starterRegistry - publicNetworkAccess: 'Enabled' - policies: { azureADAuthenticationAsArmPolicy: { status: 'enabled' } } - } -} - -resource network 'Microsoft.Network/virtualNetworks@2024-05-01' = { - name: '${prefix}-network' - location: location - tags: ownedTags - properties: { - addressSpace: { addressPrefixes: ['10.43.0.0/16'] } - subnets: [{ - name: 'containers' - properties: { - addressPrefix: '10.43.0.0/27' - delegations: [{ name: 'containers', properties: { serviceName: 'Microsoft.App/environments' } }] - serviceEndpoints: [{ service: 'Microsoft.Sql', locations: [location] }] - } - }] - } -} - -resource logs 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { - name: '${prefix}-logs' - location: location - tags: ownedTags - properties: { - sku: { name: 'PerGB2018' } - retentionInDays: logRetentionDays - workspaceCapping: { dailyQuotaGb: json('0.1') } - publicNetworkAccessForIngestion: 'Enabled' - publicNetworkAccessForQuery: 'Enabled' - features: { disableLocalAuth: true } - } -} - -resource environment 'Microsoft.App/managedEnvironments@2024-03-01' = { - name: '${prefix}-containers' - location: location - tags: ownedTags - properties: { - vnetConfiguration: { infrastructureSubnetId: '${network.id}/subnets/containers', internal: false } - workloadProfiles: [{ name: 'Consumption', workloadProfileType: 'Consumption' }] - appLogsConfiguration: { destination: 'azure-monitor' } - } -} - -resource environmentDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { - scope: environment - name: 'demo-console-logs' - properties: { - workspaceId: logs.id - logs: [{ categoryGroup: 'allLogs', enabled: true }] - } -} - -resource sql 'Microsoft.Sql/servers@2023-08-01' = { - name: '${prefix}-sql-${suffix}' - location: location - tags: ownedTags - properties: { - version: '12.0' - minimalTlsVersion: '1.2' - publicNetworkAccess: 'Enabled' - administrators: { - administratorType: 'ActiveDirectory' - login: sqlAdminName - sid: sqlAdminObjectId - tenantId: tenantId - azureADOnlyAuthentication: true - } - } -} - -resource database 'Microsoft.Sql/servers/databases@2023-08-01' = { - parent: sql - name: 'app' - location: location - tags: ownedTags - sku: freeSql ? freeSqlSku : sqlSku - properties: union({ - collation: 'SQL_Latin1_General_CP1_CI_AS' - maxSizeBytes: freeSql ? 34359738368 : sqlMaxSizeBytes - requestedBackupStorageRedundancy: freeSql ? 'Local' : sqlBackupRedundancy - zoneRedundant: false - useFreeLimit: freeSql - }, freeSql ? { - freeLimitExhaustionBehavior: 'AutoPause' - autoPauseDelay: 60 - minCapacity: json('0.5') - } : {}) -} - -resource subnetRule 'Microsoft.Sql/servers/virtualNetworkRules@2023-08-01' = { - parent: sql - name: 'selected-application-subnet' - properties: { - virtualNetworkSubnetId: '${network.id}/subnets/containers' - ignoreMissingVnetServiceEndpoint: false - } -} - -output environmentId string = environment.id -output environmentName string = environment.name -output applicationName string = '${prefix}-web' -output runtimeIdentityResourceId string = runtimeIdentity.id -output runtimeIdentityName string = runtimeIdentity.name -output runtimeIdentityClientId string = runtimeIdentity.properties.clientId -output runtimeIdentityPrincipalId string = runtimeIdentity.properties.principalId -output runtimeIdentityTenantId string = runtimeIdentity.properties.tenantId -output registryId string = registry.id -output registryName string = registry.name -output registryServer string = registry.properties.loginServer -output networkId string = network.id -output subnetId string = '${network.id}/subnets/containers' -output sqlServerId string = sql.id -output sqlServerName string = sql.name -output sqlHost string = sql.properties.fullyQualifiedDomainName -output databaseId string = database.id -output databaseName string = database.name -output ownershipTags object = ownedTags -output sqlBillingMode string = sqlBillingMode -output logsWorkspaceId string = logs.id -output logsWorkspaceName string = logs.name diff --git a/infra/main.bicep b/infra/main.bicep index cf7f6ad..6d05248 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -9,15 +9,12 @@ param environment string param location string = resourceGroup().location param tenantId string param apiClientId string -@allowed(['foundation', 'role-based-data']) -param profile string = 'foundation' -param requiredRole string = '' -param readinessPath string = '' +param requiredRole string +param readinessPath string param sqlAdminObjectId string param sqlAdminName string param gatewayImage string param dabImage string -param functionsImage string = '' param registryServer string param deployGateway bool = true @description('Explicit point-in-time restore window for the application database. Longer retention increases backup storage cost and must be reviewed.') @@ -26,22 +23,12 @@ param deployGateway bool = true param sqlBackupRetentionDays int = 7 @description('Explicit backup storage redundancy for the application database.') @allowed(['Local', 'Zone', 'Geo', 'GeoZone']) -param sqlBackupRedundancy string = 'Geo' +param sqlBackupRedundancy string = 'Local' var prefix = '${name}-${environment}' -var fullFoundation = profile == 'foundation' var suffix = uniqueString(resourceGroup().id, prefix) var sqlName = '${prefix}-${suffix}' -var storageName = 'sqlapps${suffix}' -var vaultName = 'sqlapps-${suffix}' -var functionName = '${prefix}-fn-${suffix}' var tags = { project: 'sql-apps', environment: environment } -var blobContributor = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') -var blobOwner = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') -var queueContributor = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88') -var tableContributor = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3') -var acrPull = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - resource gatewayIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { name: '${prefix}-gateway' location: location @@ -52,24 +39,6 @@ resource dabIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-3 location: location tags: tags } -resource functionIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = if (fullFoundation) { - name: '${prefix}-functions' - location: location - tags: tags -} -resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { - name: split(registryServer, '.')[0] -} -resource pullRoles 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for index in range(0, fullFoundation ? 3 : 2): { - name: guid(registry.id, ['gateway', 'data', 'functions'][index], prefix, acrPull) - scope: registry - properties: { - roleDefinitionId: acrPull - principalId: index == 0 ? gatewayIdentity.properties.principalId : index == 1 ? dabIdentity.properties.principalId : functionIdentity!.properties.principalId - principalType: 'ServicePrincipal' - } -}] - resource network 'Microsoft.Network/virtualNetworks@2024-05-01' = { name: '${prefix}-network' location: location @@ -84,13 +53,6 @@ resource network 'Microsoft.Network/virtualNetworks@2024-05-01' = { delegations: [{ name: 'containers', properties: { serviceName: 'Microsoft.App/environments' } }] } } - ...(fullFoundation ? [{ - name: 'functions' - properties: { - addressPrefix: '10.42.2.0/24' - delegations: [{ name: 'functions', properties: { serviceName: 'Microsoft.Web/serverFarms' } }] - } - }] : []) { name: 'endpoints', properties: { addressPrefix: '10.42.3.0/24', privateEndpointNetworkPolicies: 'Disabled' } } ] } @@ -99,14 +61,7 @@ resource logs 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { name: '${prefix}-logs' location: location tags: tags - properties: { sku: { name: 'PerGB2018' }, retentionInDays: 30 } -} -resource insights 'Microsoft.Insights/components@2020-02-02' = if (fullFoundation) { - name: '${prefix}-insights' - location: location - kind: 'web' - tags: tags - properties: { Application_Type: 'web', WorkspaceResourceId: logs.id } + properties: { sku: { name: 'PerGB2018' }, retentionInDays: 30, workspaceCapping: { dailyQuotaGb: json('0.1') } } } resource containers 'Microsoft.App/managedEnvironments@2024-03-01' = { name: '${prefix}-containers' @@ -143,11 +98,16 @@ resource database 'Microsoft.Sql/servers/databases@2023-08-01' = { name: 'app' location: location tags: tags - sku: { name: 'S0', tier: 'Standard', capacity: 10 } + sku: { name: 'GP_S_Gen5_2', tier: 'GeneralPurpose', family: 'Gen5', capacity: 2 } properties: { collation: 'SQL_Latin1_General_CP1_CI_AS' zoneRedundant: false requestedBackupStorageRedundancy: sqlBackupRedundancy + maxSizeBytes: 34359738368 + useFreeLimit: true + freeLimitExhaustionBehavior: 'AutoPause' + autoPauseDelay: 60 + minCapacity: json('0.5') } } resource backupRetention 'Microsoft.Sql/servers/databases/backupShortTermRetentionPolicies@2023-08-01' = { @@ -176,132 +136,11 @@ resource sqlAuditLogs 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' ] } } -resource files 'Microsoft.Storage/storageAccounts@2023-05-01' = if (fullFoundation) { - name: storageName - location: location - tags: tags - sku: { name: 'Standard_LRS' } - kind: 'StorageV2' - properties: { - minimumTlsVersion: 'TLS1_2' - supportsHttpsTrafficOnly: true - allowBlobPublicAccess: false - allowSharedKeyAccess: false - publicNetworkAccess: 'Disabled' - networkAcls: { defaultAction: 'Deny', bypass: 'None' } - } -} -resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = if (fullFoundation) { - parent: files - name: 'default' - properties: { deleteRetentionPolicy: { enabled: true, days: 7 }, containerDeleteRetentionPolicy: { enabled: true, days: 7 } } -} -resource fileContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = if (fullFoundation) { - parent: blobService - name: 'files' - properties: { publicAccess: 'None' } -} -resource hostContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = if (fullFoundation) { - parent: blobService - name: 'azure-webjobs-hosts' - properties: { publicAccess: 'None' } -} -resource gatewayStorageRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (fullFoundation) { - name: guid(fileContainer.id, gatewayIdentity.id, blobContributor) - scope: fileContainer - properties: { roleDefinitionId: blobContributor, principalId: gatewayIdentity.properties.principalId, principalType: 'ServicePrincipal' } -} -resource hostStorageRoles 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for role in (fullFoundation ? [blobOwner, queueContributor, tableContributor] : []): { - name: guid(files.id, functionIdentity.id, role) - scope: files - properties: { roleDefinitionId: role, principalId: functionIdentity!.properties.principalId, principalType: 'ServicePrincipal' } -}] -resource vault 'Microsoft.KeyVault/vaults@2023-07-01' = if (fullFoundation) { - name: vaultName - location: location - tags: tags - properties: { - tenantId: tenantId - sku: { family: 'A', name: 'standard' } - enableRbacAuthorization: true - enableSoftDelete: true - enablePurgeProtection: true - softDeleteRetentionInDays: 90 - publicNetworkAccess: 'Disabled' - networkAcls: { defaultAction: 'Deny', bypass: 'None' } - } -} -resource functionSecretsRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (fullFoundation) { - name: guid(vault.id, functionIdentity.id, 'secrets-user') - scope: vault - properties: { - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') - principalId: functionIdentity!.properties.principalId - principalType: 'ServicePrincipal' - } -} -resource functionPlan 'Microsoft.Web/serverfarms@2023-12-01' = if (fullFoundation) { - name: '${prefix}-functions-plan' - location: location - tags: tags - kind: 'elastic' - sku: { name: 'EP1', tier: 'ElasticPremium', capacity: 1 } - properties: { reserved: true } -} -resource functions 'Microsoft.Web/sites@2023-12-01' = if (fullFoundation) { - name: functionName - location: location - tags: tags - kind: 'functionapp,linux,container' - identity: { type: 'UserAssigned', userAssignedIdentities: { '${functionIdentity.id}': {} } } - properties: { - serverFarmId: functionPlan.id - httpsOnly: true - publicNetworkAccess: 'Disabled' - virtualNetworkSubnetId: '${network.id}/subnets/functions' - vnetRouteAllEnabled: true - vnetImagePullEnabled: true - siteConfig: { - linuxFxVersion: 'DOCKER|${functionsImage}' - acrUseManagedIdentityCreds: true - acrUserManagedIdentityID: functionIdentity!.properties.clientId - ftpsState: 'Disabled' - minTlsVersion: '1.2' - appSettings: [ - { name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4' } - { name: 'FUNCTIONS_WORKER_RUNTIME', value: 'node' } - { name: 'WEBSITES_ENABLE_APP_SERVICE_STORAGE', value: 'false' } - { name: 'AZURE_TENANT_ID', value: tenantId } - { name: 'API_CLIENT_ID', value: apiClientId } - { name: 'GATEWAY_PRINCIPAL_ID', value: gatewayIdentity.properties.principalId } - { name: 'FUNCTIONS_IDENTITY_CLIENT_ID', value: functionIdentity!.properties.clientId } - { name: 'KEY_VAULT_URL', value: vault!.properties.vaultUri } - { name: 'AzureWebJobsStorage__accountName', value: files.name } - { name: 'AzureWebJobsStorage__credential', value: 'managedidentity' } - { name: 'AzureWebJobsStorage__clientId', value: functionIdentity!.properties.clientId } - { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: insights!.properties.ConnectionString } - ] - } - } - dependsOn: [hostStorageRoles, pullRoles] -} module sqlEndpoint 'private-endpoint.bicep' = { name: 'sql-endpoint' params: { name: '${prefix}-sql', location: location, networkId: network.id, targetId: sql.id, groupId: 'sqlServer', dnsZone: 'privatelink${az.environment().suffixes.sqlServerHostname}' } } -module storageEndpoints 'private-endpoint.bicep' = [for kind in (fullFoundation ? ['blob', 'queue', 'table'] : []): { - name: '${kind}-endpoint' - params: { name: '${prefix}-${kind}', location: location, networkId: network.id, targetId: files.id, groupId: kind, dnsZone: 'privatelink.${kind}.core.windows.net' } -}] -module vaultEndpoint 'private-endpoint.bicep' = if (fullFoundation) { - name: 'vault-endpoint' - params: { name: '${prefix}-vault', location: location, networkId: network.id, targetId: vault.id, groupId: 'vault', dnsZone: 'privatelink.vaultcore.azure.net' } -} -module functionEndpoint 'private-endpoint.bicep' = if (fullFoundation) { - name: 'function-endpoint' - params: { name: '${prefix}-function', location: location, networkId: network.id, targetId: functions.id, groupId: 'sites', dnsZone: 'privatelink.azurewebsites.net' } -} resource dataApp 'Microsoft.App/containerApps@2024-03-01' = if (deployGateway) { name: '${prefix}-data' location: location @@ -316,7 +155,7 @@ resource dataApp 'Microsoft.App/containerApps@2024-03-01' = if (deployGateway) { registries: [{ server: registryServer, identity: dabIdentity.id }] } template: { - scale: { minReplicas: 1, maxReplicas: 2 } + scale: { minReplicas: 0, maxReplicas: 2 } containers: [{ name: 'data' image: dabImage @@ -334,7 +173,7 @@ resource dataApp 'Microsoft.App/containerApps@2024-03-01' = if (deployGateway) { }] } } - dependsOn: [pullRoles, sqlEndpoint] + dependsOn: [sqlEndpoint] } resource gateway 'Microsoft.App/containerApps@2024-03-01' = if (deployGateway) { name: '${prefix}-gateway' @@ -350,7 +189,7 @@ resource gateway 'Microsoft.App/containerApps@2024-03-01' = if (deployGateway) { registries: [{ server: registryServer, identity: gatewayIdentity.id }] } template: { - scale: { minReplicas: 1, maxReplicas: 2 } + scale: { minReplicas: 0, maxReplicas: 2 } containers: [{ name: 'gateway' image: gatewayImage @@ -360,15 +199,8 @@ resource gateway 'Microsoft.App/containerApps@2024-03-01' = if (deployGateway) { { name: 'AZURE_TENANT_ID', value: tenantId } { name: 'API_CLIENT_ID', value: apiClientId } { name: 'DAB_URL', value: 'https://${dataApp!.properties.configuration.ingress.fqdn}' } - ...(fullFoundation ? [ - { name: 'BLOB_ACCOUNT_URL', value: files!.properties.primaryEndpoints.blob } - { name: 'BLOB_CONTAINER', value: fileContainer!.name } - { name: 'FUNCTIONS_URL', value: 'https://${functions!.properties.defaultHostName}' } - ] : [ - { name: 'SQL_APPS_PROFILE', value: profile } - { name: 'SQL_APPS_REQUIRED_ROLE', value: requiredRole } - { name: 'SQL_APPS_READINESS_PATH', value: readinessPath } - ]) + { name: 'SQL_APPS_REQUIRED_ROLE', value: requiredRole } + { name: 'SQL_APPS_READINESS_PATH', value: readinessPath } ] probes: [ { type: 'Liveness', httpGet: { path: '/health/live', port: 8080 }, initialDelaySeconds: 10, periodSeconds: 30 } @@ -377,7 +209,6 @@ resource gateway 'Microsoft.App/containerApps@2024-03-01' = if (deployGateway) { }] } } - dependsOn: [gatewayStorageRole, storageEndpoints, functionEndpoint, pullRoles] } output gatewayUrl string = deployGateway ? 'https://${gateway!.properties.configuration.ingress.fqdn}' : '' @@ -386,10 +217,7 @@ output databaseName string = database.name output dabPrincipalId string = dabIdentity.properties.principalId output gatewayPrincipalId string = gatewayIdentity.properties.principalId output gatewayName string = '${prefix}-gateway' -output functionsName string = fullFoundation ? functions!.name : '' output networkId string = network.id -output storageAccount string = fullFoundation ? files!.name : '' -output vaultName string = fullFoundation ? vault!.name : '' output sqlBackupRetentionDays int = sqlBackupRetentionDays output sqlBackupRedundancy string = sqlBackupRedundancy output sqlAuditWorkspaceName string = logs.name diff --git a/infra/rbac.bicep b/infra/rbac.bicep new file mode 100644 index 0000000..705c9e7 --- /dev/null +++ b/infra/rbac.bicep @@ -0,0 +1,29 @@ +targetScope = 'resourceGroup' + +@minLength(3) +@maxLength(20) +param name string = 'sqlapps' +@allowed(['dev', 'test', 'prod']) +param environment string +param registryServer string +param gatewayPrincipalId string +param dabPrincipalId string + +var prefix = '${name}-${environment}' +var acrPull = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + +resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { + name: split(registryServer, '.')[0] +} + +resource pullRoles 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for (principal, index) in [gatewayPrincipalId, dabPrincipalId]: { + name: guid(registry.id, ['gateway', 'data'][index], prefix, acrPull) + scope: registry + properties: { + roleDefinitionId: acrPull + principalId: principal + principalType: 'ServicePrincipal' + } +}] + +output assignmentCount int = 2 \ No newline at end of file diff --git a/infra/static.Dockerfile b/infra/static.Dockerfile deleted file mode 100644 index 60fe736..0000000 --- a/infra/static.Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -# check=skip=InvalidDefaultArgInFrom -# Require the deployed runtime; never default to another image. -ARG RUNTIME_IMAGE -FROM ${RUNTIME_IMAGE} -COPY --chown=node:node public/ /app/public/ diff --git a/kimi-marketplace.json b/kimi-marketplace.json index 1627611..b62091d 100644 --- a/kimi-marketplace.json +++ b/kimi-marketplace.json @@ -3,10 +3,10 @@ "plugins": [{ "id": "sql-apps", "displayName": "SQL Apps", - "version": "0.1.2", - "description": "Build and deploy this independent Azure-native TypeScript stack.", + "version": "0.2.0", + "description": "Build and validate Azure SQL Database and Data API Builder applications; use maintained Azure skills for the general cloud lifecycle.", "homepage": "https://github.com/microsoft/sql-apps", - "keywords": ["azure", "bicep", "typescript"], + "keywords": ["azure-sql", "typescript"], "source": "https://github.com/microsoft/sql-apps" }] } diff --git a/local-settings.example.json b/local-settings.example.json deleted file mode 100644 index f7c3582..0000000 --- a/local-settings.example.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "processing": true, - "automaticProgress": true, - "jobRetry": true, - "tracing": true, - "progressIntervalMs": 2000, - "staleJobMinutes": 60, - "retentionDays": 0, - "traceRetentionDays": 7 -} diff --git a/package-lock.json b/package-lock.json index 6b57fca..a45e1e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,28 +1,20 @@ { "name": "sql-apps", - "version": "0.1.2", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sql-apps", - "version": "0.1.2", + "version": "0.2.0", "dependencies": { - "@azure/functions": "4.16.5", - "@azure/identity": "4.13.3", - "@azure/keyvault-secrets": "4.11.2", "@azure/msal-browser": "5.25.0", - "@azure/storage-blob": "12.34.0", - "@azure/storage-queue": "12.32.0", "@fastify/static": "10.1.5", - "@opentelemetry/api": "1.9.1", - "@opentelemetry/sdk-trace-base": "2.11.0", "fastify": "5.12.5", "jose": "6.2.12", "zod": "4.6.5" }, "devDependencies": { - "@azure/static-web-apps-cli": "2.0.10", "@types/node": "22.20.4", "ajv": "8.20.0", "ajv-formats": "3.0.1", @@ -33,358 +25,6 @@ "node": ">=22 <23 || >=24 <25" } }, - "node_modules/@azure-rest/core-client": { - "version": "2.9.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure-rest/core-client/-/core-client-2.9.0.tgz", - "integrity": "sha1-4MLGSgtLNk1R/w7+0S74izbNYFo=", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-rest-pipeline": "^1.24.0", - "@azure/core-tracing": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.8", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/abort-controller": { - "version": "2.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/abort-controller/-/abort-controller-2.2.0.tgz", - "integrity": "sha1-k+DoKwGGLn6jtbaZWHGdPz/SyKw=", - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/arm-appservice": { - "version": "15.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/arm-appservice/-/arm-appservice-15.0.0.tgz", - "integrity": "sha1-tOzo4ZgHXdZYisJ9zpxK+juDAGQ=", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-auth": "^1.6.0", - "@azure/core-client": "^1.7.0", - "@azure/core-lro": "^2.5.4", - "@azure/core-paging": "^1.2.0", - "@azure/core-rest-pipeline": "^1.14.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/arm-appservice/node_modules/@azure/abort-controller": { - "version": "1.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/abort-controller/-/abort-controller-1.1.0.tgz", - "integrity": "sha1-eI7nhFelWvihrTQqyxgjg9IRkkk=", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@azure/arm-resources": { - "version": "5.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/arm-resources/-/arm-resources-5.2.0.tgz", - "integrity": "sha1-xiBCl23pI+AEgEJRHOwkzOuZigk=", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-auth": "^1.3.0", - "@azure/core-client": "^1.7.0", - "@azure/core-lro": "^2.5.0", - "@azure/core-paging": "^1.2.0", - "@azure/core-rest-pipeline": "^1.8.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@azure/arm-resources/node_modules/@azure/abort-controller": { - "version": "1.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/abort-controller/-/abort-controller-1.1.0.tgz", - "integrity": "sha1-eI7nhFelWvihrTQqyxgjg9IRkkk=", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@azure/arm-subscriptions": { - "version": "5.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/arm-subscriptions/-/arm-subscriptions-5.1.1.tgz", - "integrity": "sha1-SCa93tueydSDdfut51kzeMx/gWE=", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.9.0", - "@azure/core-client": "^1.9.2", - "@azure/core-lro": "^2.2.0", - "@azure/core-paging": "^1.6.2", - "@azure/core-rest-pipeline": "^1.19.0", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-auth": { - "version": "1.11.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/core-auth/-/core-auth-1.11.0.tgz", - "integrity": "sha1-Ha7/32LRqHHSK4ZfnzFkpj6h9XU=", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-util": "^1.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/core-client": { - "version": "1.11.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/core-client/-/core-client-1.11.1.tgz", - "integrity": "sha1-2ZCHiohXdQerETGTW0c0sppjfVg=", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-rest-pipeline": "^1.22.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/core-http-compat": { - "version": "2.5.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/core-http-compat/-/core-http-compat-2.5.0.tgz", - "integrity": "sha1-14BmGln4pDL+yyt8b6LVgi3KHeo=", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2" - }, - "engines": { - "node": ">=22.0.0" - }, - "peerDependencies": { - "@azure/core-client": "^1.10.0", - "@azure/core-rest-pipeline": "^1.22.0" - } - }, - "node_modules/@azure/core-lro": { - "version": "2.7.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/core-lro/-/core-lro-2.7.2.tgz", - "integrity": "sha1-eHEFAnog5Fx3ZRqYsBpNOwG3Wgg=", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-util": "^1.2.0", - "@azure/logger": "^1.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-paging": { - "version": "1.7.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/core-paging/-/core-paging-1.7.0.tgz", - "integrity": "sha1-WVl6L1Q4ujxsh4n8hw9p4OeFMag=", - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/core-process": { - "version": "1.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/core-process/-/core-process-1.0.0.tgz", - "integrity": "sha1-w68cFyI9ZTjhs2ln3clM2uUyy4M=", - "license": "MIT", - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/core-rest-pipeline": { - "version": "1.25.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", - "integrity": "sha1-kupJEu27H3OV9IKaMAYxF0qoLwg=", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/core-tracing": { - "version": "1.4.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/core-tracing/-/core-tracing-1.4.0.tgz", - "integrity": "sha1-1lenAOJNJW0ZXmKKeV22qMxHXqs=", - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/core-util": { - "version": "1.14.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/core-util/-/core-util-1.14.0.tgz", - "integrity": "sha1-fOn+V5WPEy3UIlc4VuWkXHIyuwQ=", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/core-xml": { - "version": "1.6.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/core-xml/-/core-xml-1.6.0.tgz", - "integrity": "sha1-EI8JIOG834owuffoh5ibOhIIK9k=", - "license": "MIT", - "dependencies": { - "fast-xml-parser": "^5.5.9", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/functions": { - "version": "4.16.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/functions/-/functions-4.16.5.tgz", - "integrity": "sha1-mP18vAvEb5ZJO89Oz4iCrDYQghQ=", - "license": "MIT", - "dependencies": { - "@azure/functions-extensions-base": "0.3.0", - "cookie": "^0.7.0" - }, - "engines": { - "node": ">=20.0" - } - }, - "node_modules/@azure/functions-extensions-base": { - "version": "0.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/functions-extensions-base/-/functions-extensions-base-0.3.0.tgz", - "integrity": "sha1-cGLy8GmLtkdwN4+eev+fd8Ukn04=", - "license": "MIT", - "engines": { - "node": ">=18.0" - } - }, - "node_modules/@azure/identity": { - "version": "4.13.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/identity/-/identity-4.13.3.tgz", - "integrity": "sha1-93gb4IncYjHheuFFS5oekbx5e4E=", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.9.0", - "@azure/core-client": "^1.9.2", - "@azure/core-process": "^1.0.0", - "@azure/core-rest-pipeline": "^1.17.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.11.0", - "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^5.5.0", - "@azure/msal-node": "^6.0.0", - "open": "^10.1.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/keyvault-common": { - "version": "2.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/keyvault-common/-/keyvault-common-2.1.0.tgz", - "integrity": "sha1-Zw50FKK/aDcbCz0fmByboCeLd90=", - "license": "MIT", - "dependencies": { - "@azure-rest/core-client": "^2.3.3", - "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.3.0", - "@azure/core-rest-pipeline": "^1.8.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.10.0", - "@azure/logger": "^1.1.4", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/keyvault-secrets": { - "version": "4.11.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/keyvault-secrets/-/keyvault-secrets-4.11.2.tgz", - "integrity": "sha1-Ngm9ZSp6Nn8UU17FWfgXhkMkr8k=", - "license": "MIT", - "dependencies": { - "@azure-rest/core-client": "^2.3.3", - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.9.0", - "@azure/core-lro": "^2.7.2", - "@azure/core-paging": "^1.6.2", - "@azure/core-rest-pipeline": "^1.19.0", - "@azure/core-tracing": "^1.2.0", - "@azure/core-util": "^1.11.0", - "@azure/keyvault-common": "^2.1.0", - "@azure/logger": "^1.1.4", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/logger": { - "version": "1.4.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/logger/-/logger-1.4.0.tgz", - "integrity": "sha1-PVpif+WaJ27OdIenndkq1cUIwzk=", - "license": "MIT", - "dependencies": { - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, "node_modules/@azure/msal-browser": { "version": "5.25.0", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/msal-browser/-/msal-browser-5.25.0.tgz", @@ -406,227 +46,6 @@ "node": ">=0.8.0" } }, - "node_modules/@azure/msal-node": { - "version": "6.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/msal-node/-/msal-node-6.0.1.tgz", - "integrity": "sha1-SLnVpHLxa0A706MklCJ+z8VMr9Q=", - "license": "MIT", - "dependencies": { - "@azure/msal-common": "16.14.1", - "jsonwebtoken": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { - "version": "16.14.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/msal-common/-/msal-common-16.14.1.tgz", - "integrity": "sha1-LJh/MdqFjlROcrb6qYb+zbPeIPg=", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/static-web-apps-cli": { - "version": "2.0.10", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/static-web-apps-cli/-/static-web-apps-cli-2.0.10.tgz", - "integrity": "sha1-7Huby8xDRYFEWtdhOzM6/DowlK4=", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/arm-appservice": "^15.0.0", - "@azure/arm-resources": "^5.2.0", - "@azure/arm-subscriptions": "^5.1.0", - "@azure/identity": "^4.3.0", - "@azure/msal-common": "^14.13.0", - "adm-zip": "^0.5.14", - "chalk": "^4.1.2", - "cli-progress": "^3.12.0", - "commander": "^9.5.0", - "concurrently": "^7.6.0", - "cookie": "^0.7.0", - "devcert": "^1.2.0", - "dotenv": "^16.4.5", - "finalhandler": "^1.2.0", - "get-port": "^5.1.1", - "globrex": "^0.1.2", - "http-proxy": "^1.18.1", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "internal-ip": "^6.2.0", - "json-schema-library": "^9.3.5", - "json-source-map": "^0.6.1", - "jwt-decode": "^4.0.0", - "keytar": "^7.9.0", - "node-fetch": "^2.7.0", - "open": "^8.4.2", - "openid-client": "^6.7.1", - "ora": "^5.4.1", - "pem": "^1.14.8", - "prompts": "^2.4.2", - "rimraf": "^5.0.7", - "selfsigned": "^2.4.1", - "serve-static": "^1.15.0", - "update-notifier": "^7.0.0", - "wait-on": "^7.2.0", - "yaml": "^2.0.1" - }, - "bin": { - "swa": "dist/cli/bin.js" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=9.0.0" - } - }, - "node_modules/@azure/static-web-apps-cli/node_modules/@azure/msal-common": { - "version": "14.16.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/msal-common/-/msal-common-14.16.1.tgz", - "integrity": "sha1-0j7M5AgjpNA610Fg3IGdYuDAp4c=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/static-web-apps-cli/node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha1-P3rkIRKbyqrJvHSQXJigAJ7J7n8=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@azure/static-web-apps-cli/node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha1-M+6r4jz+hvFL3kQIoCwM+4U6zao=", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@azure/static-web-apps-cli/node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha1-dKTHbnfKn9P5MvKQwX6jJs0VcnE=", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@azure/static-web-apps-cli/node_modules/open": { - "version": "8.4.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/open/-/open-8.4.2.tgz", - "integrity": "sha1-W1/+Ko95Pc0qrXPlUMuHtZywhPk=", - "dev": true, - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@azure/storage-blob": { - "version": "12.34.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/storage-blob/-/storage-blob-12.34.0.tgz", - "integrity": "sha512-cXDTCAMwDx8Ng9yUe82kOOd2kPnZ8AQqe4FkLMcpaQ3Mvr3/PXVdvRhyP0FtY5/U5Oh6JGk3EcEibaFOymRLFw==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.9.0", - "@azure/core-client": "^1.9.3", - "@azure/core-http-compat": "^2.2.0", - "@azure/core-lro": "^2.2.0", - "@azure/core-paging": "^1.6.2", - "@azure/core-rest-pipeline": "^1.19.1", - "@azure/core-tracing": "^1.2.0", - "@azure/core-util": "^1.11.0", - "@azure/core-xml": "^1.4.5", - "@azure/logger": "^1.1.4", - "@azure/storage-common": "^12.5.0", - "apache-arrow": "^21.1.0", - "events": "^3.0.0", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/storage-common": { - "version": "12.5.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/storage-common/-/storage-common-12.5.0.tgz", - "integrity": "sha1-nbHWmPJu9LVqVKYABGp2XqG7oxY=", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.9.0", - "@azure/core-http-compat": "^2.4.0", - "@azure/core-rest-pipeline": "^1.24.0", - "@azure/core-tracing": "^1.2.0", - "@azure/core-util": "^1.11.0", - "@azure/logger": "^1.1.4", - "events": "^3.3.0", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/storage-queue": { - "version": "12.32.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/storage-queue/-/storage-queue-12.32.0.tgz", - "integrity": "sha512-jMtVg87egqEanN4lb5mXViAYAsjZshLhqgx35f1Xr+qjVF7eh9eI0u2dMuDW/IQXrNK6H9rXUbS3xap2XeI88g==", - "license": "MIT", - "dependencies": { - "@azure-rest/core-client": "^2.7.0", - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.1", - "@azure/core-client": "^1.10.1", - "@azure/core-http-compat": "^2.4.0", - "@azure/core-paging": "^1.6.2", - "@azure/core-rest-pipeline": "^1.24.0", - "@azure/core-tracing": "^1.3.1", - "@azure/core-util": "^1.13.1", - "@azure/logger": "^1.3.0", - "@azure/storage-common": "^12.5.0", - "fast-xml-parser": "^5.5.9", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha1-EgIkUMRaTabY2Ch7GKT/Ldsj92g=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -1260,4378 +679,215 @@ "glob": "^13.0.0" } }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha1-g2iGnctzW+Ln9ct2R9544WeiUfs=", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha1-3ESOMyxsbjek3AL9hLqNRLmvsBI=", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha1-s3Znt7wYHBaHgiWbq0JHT79StVA=", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha1-B/CeWadMUvTYjG21wQVOgZU44qg=", + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.4.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-6.4.0.tgz", - "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha1-wETV3MUhoHZBNHJZehrLHxA8QEE=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha1-hAyIA7DYBH9P8M+WMXazLU7z7XI=", - "dev": true, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha1-w94GDdEmQNzIOFFqoqaAPMey6dY=", "license": "MIT" }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha1-FPja7G2B5yIdKjV+Zoyrc728p5Q=", + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", "dev": true, "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "undici-types": "~6.21.0" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha1-0iomlSKDamJ6+NBLXD/Sx/o+MuM=", - "dev": true, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha1-aww3HfIS23EptX0uf88oK4vxyDk=", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha1-MEs2Nq3Yi6fZNnYN1Q7OAG3qlfk=", "license": "MIT", "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha1-VtwiNo7lcPrOG0mBmXXZuaXq0hQ=", - "dev": true, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha1-PV3HYryhdnnDwup+kK1rdTIwlXg=", "license": "MIT", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "ajv": "^8.0.0" }, - "engines": { - "node": ">=12" + "peerDependencies": { + "ajv": "^8.0.0" }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@lukeed/ms": { - "version": "2.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lukeed/ms/-/ms-2.0.2.tgz", - "integrity": "sha1-B/CeWadMUvTYjG21wQVOgZU44qg=", - "license": "MIT", - "engines": { - "node": ">=8" + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha1-zuQ9gB/O+WRLEbgZSFdpWs1fgVo=", - "dev": true, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha1-64W3emAfyTLP5DLFrNNkqeLJB1s=", "license": "MIT", "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": ">=8.0.0" } }, - "node_modules/@nodable/entities": { - "version": "3.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@nodable/entities/-/entities-3.1.0.tgz", - "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==", + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha1-5LwzzikakIhzrq1ybHOqtv82TxI=", "funding": [ { "type": "github", - "url": "https://github.com/sponsors/nodable" + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" } ], - "license": "MIT" - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha1-wbA0beM2ulWvLVp5cIggN7rt7AU=", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/core": { - "version": "2.11.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.11.0.tgz", - "integrity": "sha1-l4SdOAi7plJWi31T4gyfw4vLfEk=", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/resources": { - "version": "2.11.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.11.0.tgz", - "integrity": "sha1-tOYLqSpPEi3JefNAKuqXE6mOrFs=", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.11.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace": { - "version": "2.11.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-trace/-/sdk-trace-2.11.0.tgz", - "integrity": "sha1-Rru6nTZnVd99b63tD3zSWvfyZcI=", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.11.0", - "@opentelemetry/resources": "2.11.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.11.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.11.0.tgz", - "integrity": "sha1-P3JUAfz7FuWyM1wQPNyUiIKWMtE=", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.11.0", - "@opentelemetry/resources": "2.11.0", - "@opentelemetry/sdk-trace": "2.11.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.43.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", - "integrity": "sha1-8/Rn42wnMy8Oc17IbNzXjdbyeGU=", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@peculiar/asn1-cms": { - "version": "2.10.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@peculiar/asn1-cms/-/asn1-cms-2.10.0.tgz", - "integrity": "sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.10.0", - "@peculiar/asn1-x509": "^2.10.0", - "@peculiar/asn1-x509-attr": "^2.10.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@peculiar/asn1-csr": { - "version": "2.10.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@peculiar/asn1-csr/-/asn1-csr-2.10.0.tgz", - "integrity": "sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw==", - "dev": true, "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.10.0", - "@peculiar/asn1-x509": "^2.10.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14" + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" } }, - "node_modules/@peculiar/asn1-ecc": { - "version": "2.10.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@peculiar/asn1-ecc/-/asn1-ecc-2.10.0.tgz", - "integrity": "sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg==", - "dev": true, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha1-v7EGYv7tgZaixi58aOF3IMJ0F5o=", "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.10.0", - "@peculiar/asn1-x509": "^2.10.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - }, "engines": { - "node": ">=14" + "node": "18 || 20 || >=22" } }, - "node_modules/@peculiar/asn1-pfx": { - "version": "2.10.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@peculiar/asn1-pfx/-/asn1-pfx-2.10.0.tgz", - "integrity": "sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA==", - "dev": true, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.10.0", - "@peculiar/asn1-pkcs8": "^2.10.0", - "@peculiar/asn1-rsa": "^2.10.0", - "@peculiar/asn1-schema": "^2.10.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=14" + "node": "20 || >=22" } }, - "node_modules/@peculiar/asn1-pkcs8": { - "version": "2.10.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.10.0.tgz", - "integrity": "sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw==", - "dev": true, + "node_modules/content-disposition": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-disposition/-/content-disposition-3.0.0.tgz", + "integrity": "sha512-ZH/0Xs9rMIFWCOmGdmS9eHBTF62qqQYNz4nVjQhkdIO/a0fCP4UIM3mRz/wiqL0L14YgAz/1xio4OaSY4+ON/A==", "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.10.0", - "@peculiar/asn1-x509": "^2.10.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - }, "engines": { - "node": ">=14" - } - }, - "node_modules/@peculiar/asn1-pkcs9": { - "version": "2.10.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.10.0.tgz", - "integrity": "sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.10.0", - "@peculiar/asn1-pfx": "^2.10.0", - "@peculiar/asn1-pkcs8": "^2.10.0", - "@peculiar/asn1-schema": "^2.10.0", - "@peculiar/asn1-x509": "^2.10.0", - "@peculiar/asn1-x509-attr": "^2.10.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" + "node": ">=22" }, - "engines": { - "node": ">=14" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/@peculiar/asn1-rsa": { - "version": "2.10.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@peculiar/asn1-rsa/-/asn1-rsa-2.10.0.tgz", - "integrity": "sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA==", - "dev": true, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/depd/-/depd-2.0.0.tgz", + "integrity": "sha1-tpYWPMdXVg0JzyLMj60Vcbeedt8=", "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.10.0", - "@peculiar/asn1-x509": "^2.10.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - }, "engines": { - "node": ">=14" + "node": ">= 0.8" } }, - "node_modules/@peculiar/asn1-schema": { - "version": "2.10.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@peculiar/asn1-schema/-/asn1-schema-2.10.0.tgz", - "integrity": "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==", - "dev": true, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha1-JkQhTxmX057Q7g7OcjNUkKesZ74=", "license": "MIT", - "dependencies": { - "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - }, "engines": { - "node": ">=14" + "node": ">=6" } }, - "node_modules/@peculiar/asn1-x509": { - "version": "2.10.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@peculiar/asn1-x509/-/asn1-x509-2.10.0.tgz", - "integrity": "sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA==", + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha1-D0O9G62VW3LSTiJh46vllXzPCBY=", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.10.0", - "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=14" - } - }, - "node_modules/@peculiar/asn1-x509-attr": { - "version": "2.10.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.10.0.tgz", - "integrity": "sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.10.0", - "@peculiar/asn1-x509": "^2.10.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" + "node": ">=18" }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@peculiar/utils": { - "version": "2.0.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@peculiar/utils/-/utils-2.0.3.tgz", - "integrity": "sha1-onykxLc2UuEQ8Zp9FtZk9FilUo4=", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, - "node_modules/@peculiar/x509": { - "version": "1.14.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@peculiar/x509/-/x509-1.14.3.tgz", - "integrity": "sha1-LETCuJR0NGr+w4oMKAPsT7jOlZ4=", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.6.0", - "@peculiar/asn1-csr": "^2.6.0", - "@peculiar/asn1-ecc": "^2.6.0", - "@peculiar/asn1-pkcs9": "^2.6.0", - "@peculiar/asn1-rsa": "^2.6.0", - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "pvtsutils": "^1.3.6", - "reflect-metadata": "^0.2.2", - "tslib": "^2.8.1", - "tsyringe": "^4.10.0" - }, - "engines": { - "node": ">=20.0.0" - } + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", + "license": "MIT" }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha1-w94GDdEmQNzIOFFqoqaAPMey6dY=", + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha1-Rvi2wisw/3qBNX1PWav66TggJUM=", "license": "MIT" }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha1-p36nQvqyV3UUVDTrHSMoz1ATrDM=", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@pnpm/config.env-replace": { - "version": "1.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", - "integrity": "sha1-qynaU99B6JSKAPJDPwhfVN6LOkw=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.22.0" - } - }, - "node_modules/@pnpm/network.ca-file": { - "version": "1.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", - "integrity": "sha1-KrBeCcGvDN8vz1A1vqFITiIveYM=", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "4.2.10" - }, - "engines": { - "node": ">=12.22.0" - } - }, - "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { - "version": "4.2.10", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha1-FH06AG2kyjzhRyjHrvwofDZ9emw=", - "dev": true, - "license": "ISC" - }, - "node_modules/@pnpm/npm-conf": { - "version": "3.0.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pnpm/npm-conf/-/npm-conf-3.0.3.tgz", - "integrity": "sha1-F7WZghJshilKjSSKodexhfLfZIQ=", - "dev": true, - "license": "MIT", - "dependencies": { - "@pnpm/config.env-replace": "^1.1.0", - "@pnpm/network.ca-file": "^1.0.1", - "config-chain": "^1.1.11" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@sagold/json-pointer": { - "version": "5.1.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@sagold/json-pointer/-/json-pointer-5.1.2.tgz", - "integrity": "sha1-fweIQFD9ITnutddCPpFxYO5+C40=", - "dev": true, - "license": "MIT" - }, - "node_modules/@sagold/json-query": { - "version": "6.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@sagold/json-query/-/json-query-6.2.0.tgz", - "integrity": "sha1-IgSgJZ6hDzbNXLChUFB45tdR7s0=", - "dev": true, - "license": "MIT", - "dependencies": { - "@sagold/json-pointer": "^5.1.2", - "ebnf": "^1.9.1" - } - }, - "node_modules/@sideway/address": { - "version": "4.1.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@sideway/address/-/address-4.1.5.tgz", - "integrity": "sha1-S8FJoAdmI87ZnKggi6eA1lqZudU=", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha1-gPy8uvfOAx4O8t0psb/Hw/WDYR8=", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha1-z/j/rcNyrSn9P3gneusp5jLMcN8=", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@types/configstore": { - "version": "2.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/configstore/-/configstore-2.1.1.tgz", - "integrity": "sha1-zR6FU2M60xhcPy8jns/10mQ+krY=", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/debug": { - "version": "0.0.30", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/debug/-/debug-0.0.30.tgz", - "integrity": "sha1-3B5A9687nIFQE6eGDmJS9jUqhN8=", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/get-port": { - "version": "3.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/get-port/-/get-port-3.2.0.tgz", - "integrity": "sha1-+eChFEPMITNkcBherj37pEldKbw=", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/glob": { - "version": "5.0.38", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/glob/-/glob-5.0.38.tgz", - "integrity": "sha1-IOKfPGMy9rMynzRxHrsxoD3XSlE=", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/minimatch": "*", - "@types/node": "*" - } - }, - "node_modules/@types/lodash": { - "version": "4.17.25", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/lodash/-/lodash-4.17.25.tgz", - "integrity": "sha1-aXZax7zdsOsHKWHPKSUkqPWzwsA=", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/minimatch": { - "version": "5.1.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha1-B1CLRXl8uB7D8nMBGwVM0HVe3co=", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mkdirp": { - "version": "0.5.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/mkdirp/-/mkdirp-0.5.2.tgz", - "integrity": "sha1-UDqs/lzCcD1UhDJrGyfvpnoznB8=", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/node": { - "version": "22.20.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-22.20.4.tgz", - "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/rimraf": { - "version": "2.0.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/rimraf/-/rimraf-2.0.5.tgz", - "integrity": "sha1-No+wTVljC3J/wFp00spVf2So75g=", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/glob": "*", - "@types/node": "*" - } - }, - "node_modules/@types/tmp": { - "version": "0.0.33", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha1-EHPEvIJHVK49EM+riKsCN7qWTk0=", - "dev": true, - "license": "MIT" - }, - "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.9", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", - "integrity": "sha1-MyhVu9cpqzbgd5st/CTtySDRJGE=", - "license": "MIT", - "dependencies": { - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/abstract-logging": { - "version": "2.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/abstract-logging/-/abstract-logging-2.0.1.tgz", - "integrity": "sha1-aww3HfIS23EptX0uf88oK4vxyDk=", - "license": "MIT" - }, - "node_modules/adm-zip": { - "version": "0.6.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/adm-zip/-/adm-zip-0.6.1.tgz", - "integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha1-48121MVI7oldPD/Y3B9sW5Ay56g=", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha1-MEs2Nq3Yi6fZNnYN1Q7OAG3qlfk=", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha1-PV3HYryhdnnDwup+kK1rdTIwlXg=", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ansi-align": { - "version": "3.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-align/-/ansi-align-3.0.1.tgz", - "integrity": "sha1-DN8S4RGs53OobpofrRIlxDyxmlk=", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.1.0" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha1-ayKR0dt9mLZSHV8e+kLQ86n+tl4=", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha1-7dgDYornHATIWuegkG7a00tkiTc=", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anynum": { - "version": "1.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/anynum/-/anynum-1.0.1.tgz", - "integrity": "sha1-KqwA4I3603JsHUYuYNvC+DFlmkQ=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/apache-arrow": { - "version": "21.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/apache-arrow/-/apache-arrow-21.2.0.tgz", - "integrity": "sha1-OtwW64FiAmh3ggZYNs2mCS/321E=", - "license": "Apache-2.0", - "dependencies": { - "@types/node": "^25.2.0", - "flatbuffers": "^25.1.24", - "json-with-bigint": "^3.5.3", - "tslib": "^2.6.2" - }, - "bin": { - "arrow2csv": "bin/arrow2csv.js" - } - }, - "node_modules/apache-arrow/node_modules/@types/node": { - "version": "25.9.8", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-25.9.8.tgz", - "integrity": "sha512-VfMrScDmMhUJQmd5hArdQnFvK0OIeD36uN2Va1FcpYaLB/BgkgM8Ulc50XtYISPMz7APJ30+N0T5EM0jlcdfRw==", - "license": "MIT", - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } - }, - "node_modules/apache-arrow/node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha1-YSdbSF1/1OnSacfPBOwoc8nMD5E=", - "license": "MIT" - }, - "node_modules/application-config-path": { - "version": "0.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/application-config-path/-/application-config-path-0.1.1.tgz", - "integrity": "sha1-i1rGT/av3ZvXDOafb2S2mY9fdW4=", - "dev": true, - "license": "MIT" - }, - "node_modules/asn1js": { - "version": "3.0.10", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/asn1js/-/asn1js-3.0.10.tgz", - "integrity": "sha1-3ybIdMiotBymBe/qR7KtB1UQE90=", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "pvtsutils": "^1.3.6", - "pvutils": "^1.1.5", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", - "dev": true, - "license": "MIT" - }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha1-64W3emAfyTLP5DLFrNNkqeLJB1s=", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/atomically": { - "version": "2.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/atomically/-/atomically-2.1.1.tgz", - "integrity": "sha1-xU77YF7MkuHot1JjiAbSwUwx540=", - "dev": true, - "license": "MIT", - "dependencies": { - "stubborn-fs": "^2.0.0", - "when-exit": "^2.1.4" - } - }, - "node_modules/avvio": { - "version": "9.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/avvio/-/avvio-9.3.0.tgz", - "integrity": "sha1-5LwzzikakIhzrq1ybHOqtv82TxI=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/error": "^4.0.0", - "fastq": "^1.17.1" - } - }, - "node_modules/axios": { - "version": "1.20.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/axios/-/axios-1.20.0.tgz", - "integrity": "sha1-UVUTRFqmDnHQS2UhymIQgpzLR4Y=", - "dev": true, - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.6", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/axios/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha1-Sf/1hXfP7j83F2/qtMIuAPhtf3c=", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/axios/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha1-xZ7yJKBP6LdU89sAY6Jeow0ABdY=", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha1-v7EGYv7tgZaixi58aOF3IMJ0F5o=", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha1-GxtEAWClv3rUC2UPCVljSBkDkwo=", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bl/-/bl-4.1.0.tgz", - "integrity": "sha1-RRU1JkGCvsL7vIOmKrmM8R2fezo=", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/boxen": { - "version": "8.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/boxen/-/boxen-8.0.1.tgz", - "integrity": "sha1-fp/LtF4Rotfm2qj9zr/DJC/Bn+M=", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-align": "^3.0.1", - "camelcase": "^8.0.0", - "chalk": "^5.3.0", - "cli-boxes": "^3.0.0", - "string-width": "^7.2.0", - "type-fest": "^4.21.0", - "widest-line": "^5.0.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/boxen/node_modules/ansi-regex": { - "version": "6.4.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-6.4.0.tgz", - "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/boxen/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha1-sSOLbiPqM3r3HH+KKV21rwwViuo=", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/boxen/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha1-vz1uj3+P0ipl2XA0dbwBRzV6aw0=", - "dev": true, - "license": "MIT" - }, - "node_modules/boxen/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha1-tbuOIWXOJ11NQ0dt0nAK2Qkdttw=", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/boxen/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha1-0iomlSKDamJ6+NBLXD/Sx/o+MuM=", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/boxen/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha1-auHI5XMSc8K/H1itOcuuLJGkbFg=", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha1-umLnwTEzBTWCGXFghRqPZI6Z7tA=", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=", - "license": "BSD-3-Clause" - }, - "node_modules/bundle-name": { - "version": "4.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bundle-name/-/bundle-name-4.1.1.tgz", - "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytestreamjs": { - "version": "2.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bytestreamjs/-/bytestreamjs-2.0.1.tgz", - "integrity": "sha1-oylHx844mm+hGgmppWPQpFiJU14=", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha1-S1QowiK+mF15w9gmV0edvgtZstY=", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/camelcase": { - "version": "8.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/camelcase/-/camelcase-8.0.0.tgz", - "integrity": "sha1-wNNtQYdT+2rZxeBDdXl0XBwUpTQ=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha1-qsTit3NKdAhnrrFr8CqtVWoeegE=", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/charenc": { - "version": "0.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha1-b8nXtC0ypYNZYzdmbn0ICE2izGs=", - "dev": true, - "license": "ISC" - }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha1-caEMcW/uugBeRQTzYynvCxfPMUU=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha1-JkMFp65JDR0Dvwybp8kl0XU68wc=", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-progress": { - "version": "3.12.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cli-progress/-/cli-progress-3.12.0.tgz", - "integrity": "sha1-gH7hS2a8wIYljkRK0PGefUJXeUI=", - "dev": true, - "license": "MIT", - "dependencies": { - "string-width": "^4.2.3" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha1-F3Oo9LnE1qwxVj31Oz/B15Ri/kE=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha1-DASwddsCy/5g3I5s8vVIaxo2CKo=", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha1-Z+FFz/UQpqaYS98RUpEdadLrnkM=", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/clone/-/clone-1.0.4.tgz", - "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha1-ctOmjVmMm9s68q0ehPIdiWq9TeM=", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI=", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha1-w9RaizT9cwYxoRCoolIGgrMdWn8=", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/command-exists": { - "version": "1.2.9", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/command-exists/-/command-exists-1.2.9.tgz", - "integrity": "sha1-xQclrzgIyKsCYP1gsB+/oluVT2k=", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "9.5.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/commander/-/commander-9.5.0.tgz", - "integrity": "sha1-vAjR61zt98y3l6lhmdQce8PmDTA=", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || >=14" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true, - "license": "MIT" - }, - "node_modules/concurrently": { - "version": "7.6.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/concurrently/-/concurrently-7.6.0.tgz", - "integrity": "sha1-UxpvXzDPYW81Wkr7j4/LK7plpJo=", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "date-fns": "^2.29.1", - "lodash": "^4.17.21", - "rxjs": "^7.0.0", - "shell-quote": "^1.7.3", - "spawn-command": "^0.0.2-1", - "supports-color": "^8.1.0", - "tree-kill": "^1.2.2", - "yargs": "^17.3.1" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha1-zW/BfihQDP9WwbhsCn/UpUpzAFw=", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/config-chain": { - "version": "1.1.13", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha1-+tB5Wqamza/57Rto6d/5Q3LCMvQ=", - "dev": true, - "license": "MIT (https://raw.githubusercontent.com/dominictarr/config-chain/master/LICENCE)", - "dependencies": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, - "node_modules/configstore": { - "version": "7.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/configstore/-/configstore-7.1.0.tgz", - "integrity": "sha1-RbqDPS07oLi38M/1wFRKRTdK92s=", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "atomically": "^2.0.3", - "dot-prop": "^9.0.0", - "graceful-fs": "^4.2.11", - "xdg-basedir": "^5.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/content-disposition": { - "version": "3.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-disposition/-/content-disposition-3.0.0.tgz", - "integrity": "sha512-ZH/0Xs9rMIFWCOmGdmS9eHBTF62qqQYNz4nVjQhkdIO/a0fCP4UIM3mRz/wiqL0L14YgAz/1xio4OaSY4+ON/A==", - "license": "MIT", - "engines": { - "node": ">=22" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha1-VWNpxHKiupEPKXmJG1JrNDYjftc=", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha1-ilj+ePANzXDDcEUXWd+/rwPo7p8=", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/crypt": { - "version": "0.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha1-82fmRIOf9XiU7GrEgN5AyuSw9NA=", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-4.4.3.tgz", - "integrity": "sha1-xq5DLZvZZiWC/OCHCbA4xY6ePWo=", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha1-yjh2Et234QS9FthaqwDV7PCcZvw=", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha1-xPp8lUBKF6nD6Mp+FTcxK3NjMKw=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha1-RLXyFHzTsA1LVhN2hZZvJv0l3Uo=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/default-browser": { - "version": "5.5.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/default-browser/-/default-browser-5.5.1.tgz", - "integrity": "sha1-F5Cv/FJoD7sR4XyrJ1LWmqKjfSw=", - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha1-96fMuPUQS/jg9xujscz6Xq/bIeg=", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-gateway": { - "version": "6.0.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/default-gateway/-/default-gateway-6.0.3.tgz", - "integrity": "sha1-gZSUyIgFO9t0PtvzQ9bN9/KUOnE=", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "execa": "^5.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha1-sLAgYsHiqmL/XZUo8PmLqpCXjXo=", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha1-27Ga37dG1/xtc0oGty9KANAhJV8=", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/depd/-/depd-2.0.0.tgz", - "integrity": "sha1-tpYWPMdXVg0JzyLMj60Vcbeedt8=", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha1-JkQhTxmX057Q7g7OcjNUkKesZ74=", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha1-SANzVQmti+VSk0xn32FPlOZvoBU=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha1-aJxdzcGQDvVYOky59te0c3QgdK0=", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/devcert": { - "version": "1.2.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/devcert/-/devcert-1.2.3.tgz", - "integrity": "sha1-rsGgPh2RUgAUBrm9W+8mwpWPy6o=", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/configstore": "^2.1.1", - "@types/debug": "^0.0.30", - "@types/get-port": "^3.2.0", - "@types/glob": "^5.0.34", - "@types/lodash": "^4.14.92", - "@types/mkdirp": "^0.5.2", - "@types/node": "^8.5.7", - "@types/rimraf": "^2.0.2", - "@types/tmp": "^0.0.33", - "application-config-path": "^0.1.0", - "command-exists": "^1.2.4", - "debug": "^3.1.0", - "eol": "^0.9.1", - "get-port": "^3.2.0", - "glob": "^7.1.2", - "is-valid-domain": "^0.1.6", - "lodash": "^4.17.4", - "mkdirp": "^0.5.1", - "password-prompt": "^1.0.4", - "rimraf": "^2.6.2", - "sudo-prompt": "^8.2.0", - "tmp": "^0.0.33", - "tslib": "^1.10.0" - } - }, - "node_modules/devcert/node_modules/@types/node": { - "version": "8.10.66", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-8.10.66.tgz", - "integrity": "sha1-3QNdQJ3zIqzIPf9ipgLxKleDu7M=", - "dev": true, - "license": "MIT" - }, - "node_modules/devcert/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha1-6D46fj8wCzTLnYf2FfoMvzV2kO4=", - "dev": true, - "license": "MIT" - }, - "node_modules/devcert/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/devcert/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-3.2.7.tgz", - "integrity": "sha1-clgLfpFF+zm2Z2+cXl+xALk0F5o=", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/devcert/node_modules/get-port": { - "version": "3.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-port/-/get-port-3.2.0.tgz", - "integrity": "sha1-3Xzn3hh8Bsi/NTeWrHHgmfCYDrw=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/devcert/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/glob/-/glob-7.2.3.tgz", - "integrity": "sha1-uN8PuAK7+o6JvR2Ti04WV47UTys=", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/devcert/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha1-WAyI+NVEXyvWqo88re+g3nn71p4=", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/devcert/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha1-NXl/E6f9rcVmFCwp1PB8ytSD4+w=", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/devcert/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha1-zy04vcNKE0vK8QkcQfZhni9nLQA=", - "dev": true, - "license": "0BSD" - }, - "node_modules/discontinuous-range": { - "version": "1.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/discontinuous-range/-/discontinuous-range-1.0.0.tgz", - "integrity": "sha1-44Mx8IRLukm5qctxx3FYWqsbxlo=", - "dev": true, - "license": "MIT" - }, - "node_modules/dot-prop": { - "version": "9.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dot-prop/-/dot-prop-9.0.0.tgz", - "integrity": "sha1-uuWYL+bca4/duS7+9PLd/yZ3npI=", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^4.18.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dot-prop/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha1-auHI5XMSc8K/H1itOcuuLJGkbFg=", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha1-dz8OaVJ6gxXHKF1e5zxEWdIKgCA=", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha1-165mfh3INIL4tw/Q9u78UNow9Yo=", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha1-aWzi7Aqg5uqTo5f/zySqeEDIJ8s=", - "dev": true, - "license": "MIT" - }, - "node_modules/ebnf": { - "version": "1.9.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ebnf/-/ebnf-1.9.1.tgz", - "integrity": "sha1-ZMJdggjsDSIewRw8XoCUAVExqdM=", - "dev": true, - "license": "MIT", - "bin": { - "ebnf": "dist/bin.js" - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha1-rg8PothQRe8UqBfao86azQSJ5b8=", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=", - "dev": true, - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha1-e46omAd9fkCdOsRUdOo46vCFelg=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha1-c0TXEd6kDgt0q8LtSXeHQ8ztsIw=", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/eol": { - "version": "0.9.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/eol/-/eol-0.9.1.tgz", - "integrity": "sha1-9wGRL1BAdL41xhF6XEreSc1Ues0=", - "dev": true, - "license": "MIT" - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha1-mD6y+aZyTpMD9hrd8BHHLgngsPo=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha1-BfdaJdq5jk+x3NXhRywFRtUFfI8=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha1-otCzcyBXJN+lJdI7DD4bHKWCyZs=", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha1-8x274MGDsAptJutjJcgQwP0YvU0=", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es6-promisify": { - "version": "7.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es6-promisify/-/es6-promisify-7.0.0.tgz", - "integrity": "sha1-mnEACN1qSrdaieKAuteHv7dJkns=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha1-D0O9G62VW3LSTiJh46vllXzPCBY=", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha1-ARo/aYVroYnf+n3I/M6Z0qh5A+U=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-goat": { - "version": "4.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escape-goat/-/escape-goat-4.0.0.tgz", - "integrity": "sha1-lCSCAzG1ELBma5j3hz/hGsSqgIE=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/etag/-/etag-1.8.1.tgz", - "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha1-Lem2j2Uo1WRO9cWVJqG0oHMGFp8=", - "dev": true, - "license": "MIT" - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/events/-/events-3.3.0.tgz", - "integrity": "sha1-Mala0Kkk4tLEGagTrrLE6HjqdAA=", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/execa/-/execa-5.1.1.tgz", - "integrity": "sha1-+ArZy/Qpj3vR1MlVXCHpN0HEEd0=", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha1-bhSz/O4POmNA7LV9LokYaSBSpHw=", - "dev": true, - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, - "node_modules/fast-copy": { - "version": "3.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-copy/-/fast-copy-3.1.0.tgz", - "integrity": "sha1-F4d0O1OEMZ1i7nKgFyA0UQQiaAI=", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-decode-uri-component": { - "version": "1.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", - "integrity": "sha1-Rvi2wisw/3qBNX1PWav66TggJUM=", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha1-On1WtVnWy8PrUSMlJE5hmmXGxSU=", - "license": "MIT" - }, - "node_modules/fast-json-stringify": { - "version": "7.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", - "integrity": "sha1-5SihGmGW+lTt1+UTacMHZOCDnRM=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/merge-json-schemas": "^0.2.0", - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^4.0.0", - "json-schema-ref-resolver": "^3.0.0", - "rfdc": "^1.2.0" - } - }, - "node_modules/fast-json-stringify/node_modules/fast-uri": { - "version": "4.2.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-uri/-/fast-uri-4.2.1.tgz", - "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-querystring": { - "version": "1.1.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-querystring/-/fast-querystring-1.1.2.tgz", - "integrity": "sha1-ptJJN7T8b3kbTuMdy29Trq+4n1M=", - "license": "MIT", - "dependencies": { - "fast-decode-uri-component": "^1.0.1" - } - }, - "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-xml-builder": { - "version": "1.3.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", - "integrity": "sha1-7wWzU7RVl0g9+wnUUKBiorrsOoI=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.6.2", - "xml-naming": "^0.3.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.11.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz", - "integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^3.0.1", - "fast-xml-builder": "^1.2.0", - "is-unsafe": "^2.0.0", - "path-expression-matcher": "^1.6.2", - "strnum": "^2.4.2", - "xml-naming": "^0.3.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/fastify": { - "version": "5.12.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fastify/-/fastify-5.12.5.tgz", - "integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/ajv-compiler": "^4.0.5", - "@fastify/error": "^4.0.0", - "@fastify/fast-json-stringify-compiler": "^5.0.0", - "@fastify/proxy-addr": "^5.0.0", - "abstract-logging": "^2.0.1", - "avvio": "^9.0.0", - "fast-json-stringify": "^7.0.0", - "find-my-way": "^9.6.0", - "light-my-request": "^6.0.0", - "pino": "^9.14.0 || ^10.1.0", - "process-warning": "^5.1.0", - "rfdc": "^1.3.1", - "secure-json-parse": "^4.0.0", - "semver": "^7.6.0", - "toad-cache": "^3.7.0" - } - }, - "node_modules/fastify-plugin": { - "version": "6.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fastify-plugin/-/fastify-plugin-6.0.0.tgz", - "integrity": "sha1-fNZZxqrV878HtMWSBUEjUaR9Ocg=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.20.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fastq/-/fastq-1.20.3.tgz", - "integrity": "sha1-erhzHmR7VqvP7umX2uMzyj7h0E0=", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha1-HrwiKPx2c6rEpHLDEMwFt32FK4g=", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-2.6.9.tgz", - "integrity": "sha1-XRKFFd8TT/Mn6QpMk/Tgd6U2NB8=", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true, - "license": "MIT" - }, - "node_modules/find-my-way": { - "version": "9.9.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/find-my-way/-/find-my-way-9.9.0.tgz", - "integrity": "sha1-k8RD1/3z4F3YCYVDAjY9iodt8qs=", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-querystring": "^1.0.0", - "safe-regex2": "^5.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/flatbuffers": { - "version": "25.9.23", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/flatbuffers/-/flatbuffers-25.9.23.tgz", - "integrity": "sha1-NGgRVX/pMSq1ZHU155PHYenIHrE=", - "license": "Apache-2.0" - }, - "node_modules/follow-redirects": { - "version": "1.16.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/follow-redirects/-/follow-redirects-1.16.1.tgz", - "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha1-Mujp7Rtoo0l777msK2rfkqY4V28=", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha1-lSGIwcvVRgcOLdIND0HArgUwywQ=", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha1-KOhk4beG2+u2jbH0UvljUnhmWCc=", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha1-a+Dem+mYzhavivwkSXue6bfM2a0=", - "dev": true, - "license": "MIT" - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true, - "license": "ISC" - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha1-LALYZNl/PqbIgwxGTL0Rq26rehw=", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha1-T5RBKoLbMvNuOwuXQfipf+sDH34=", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.7.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", - "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha1-dD8OO2lkqTpUke0b/6rgVNf5jQE=", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-port": { - "version": "5.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-port/-/get-port-5.1.1.tgz", - "integrity": "sha1-BGntB1Y0ed5u+5hrrwU9zX1OMZM=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha1-FQs/J0OGnvPoUewMSdFbHRTQDuE=", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha1-omLY7vZ6ztV8KFKtYWdSakPL97c=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4=", - "dev": true, - "license": "MIT" - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/glob/-/glob-13.0.6.tgz", - "integrity": "sha1-B4ZmVmpCUUfMrPvS4zLetmor5x0=", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/global-directory": { - "version": "4.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha1-TXrHz9LLc/MExTuIEIkXSN9eNh4=", - "dev": true, - "license": "MIT", - "dependencies": { - "ini": "4.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/global-directory/node_modules/ini": { - "version": "4.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ini/-/ini-4.1.1.tgz", - "integrity": "sha1-2Vs9hDsekG5W1nR9VEeQT/UM56E=", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/globrex": { - "version": "0.1.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha1-3V2eyCYjJzDNZ5Ol4zqTApheYJg=", - "dev": true, - "license": "MIT" - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha1-ifVrghe9vIgCvSmd9tfxCB1+UaE=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha1-QYPk6L8Iu24Fu7L30uDI9xLKQOM=", - "dev": true, - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha1-lEdx/ZyByBJlxNaUGGDaBrtZR5s=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha1-/JxqeDoISVHQuXH+EBjegTcHozg=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha1-LNxC1AvvLltO6rfAGnPFTOerWrw=", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha1-jGLYy5C+sqrV0KW2dYGtmFTD8AM=", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha1-NtL2W8kJyHkAGN02+02T2myq4Gs=", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha1-QBVB8FNIhLv5UmAzTnL4juOXZUk=", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha1-mosfJGhmwChQlIZYX2K48sGMJw4=", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha1-2o3+rH2hMLBcK6S1nJts1mYRprk=", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha1-3JH8ukLk0G5Kuu0zs+ejwC9RTqA=", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha1-jrehCmP/8l0VpXsAFYbRd9Gw01I=", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha1-D6LGT5MpF8NDOg3tVTY6rjdBa3w=", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ini/-/ini-1.3.8.tgz", - "integrity": "sha1-op2kJbSIBvNHZ6Tvzjlyaa8oQyw=", - "dev": true, - "license": "ISC" - }, - "node_modules/internal-ip": { - "version": "6.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/internal-ip/-/internal-ip-6.2.0.tgz", - "integrity": "sha1-1VQeeXFuQGt0rGsHuFbvGNwWIcE=", - "dev": true, - "license": "MIT", - "dependencies": { - "default-gateway": "^6.0.0", - "ipaddr.js": "^1.9.1", - "is-ip": "^3.1.0", - "p-event": "^4.2.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/internal-ip?sponsor=1" - } - }, - "node_modules/internal-ip/node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha1-v/OFQ+64mEglB5/zoqjmy9RngbM=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/ip-regex": { - "version": "4.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ip-regex/-/ip-regex-4.3.0.tgz", - "integrity": "sha1-aHJ1qw9X+naXj/j03dyKI9WZDbU=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ipaddr.js": { - "version": "2.5.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ipaddr.js/-/ipaddr.js-2.5.0.tgz", - "integrity": "sha1-fUtsOfk5L7Yc+AfebkJeIsEOBh8=", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha1-76ouqdqg16suoTqXsritUf776L4=", - "dev": true, - "license": "MIT" - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha1-kAk6oxBid9inelkQ265xdH4VogA=", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha1-8Rb4Bk/pCz94RKOJl8C3UFEmnx0=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-in-ci": { - "version": "1.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-in-ci/-/is-in-ci-1.0.0.tgz", - "integrity": "sha1-moa72n5CxhKZAuBXTFSwGPu2q4g=", - "dev": true, - "license": "MIT", - "bin": { - "is-in-ci": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha1-6B+6aZZi6zHb2vJnZqYdSBRxfqQ=", - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-installed-globally": { - "version": "1.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-installed-globally/-/is-installed-globally-1.0.1.tgz", - "integrity": "sha512-li88H4QGRoNMX27jEmY8nR515oJVspAgC/WFMlhHfFV8RJ4QyGpHhXnwAe9gH5RIgKmdYRUmrOhQobcgEjIdSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "global-directory": "^4.0.1", - "is-path-inside": "^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha1-zqbmrlyHCnsKAAQHC3tYfgJSkS4=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-ip": { - "version": "3.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-ip/-/is-ip-3.1.0.tgz", - "integrity": "sha1-KuXd+vrwXLgAimIJPPKXNPZXxdg=", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-regex": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-npm": { - "version": "6.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-npm/-/is-npm-6.1.0.tgz", - "integrity": "sha1-9w4LbBMt/IF6yX07rcATSUWwmNM=", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-path-inside": { - "version": "4.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-path-inside/-/is-path-inside-4.0.0.tgz", - "integrity": "sha1-gFrrYsR8GxL8P9E7+z7R50MAcds=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha1-+sHj1TuXrVqdCunO8jifWBClwHc=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha1-PybHaoCVk7Ur+i7LVxDtJ3m1Iqc=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-unsafe": { - "version": "2.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-unsafe/-/is-unsafe-2.0.2.tgz", - "integrity": "sha1-ux6tF/GqaI9kMyWLVh6YsaRaGvw=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/is-valid-domain": { - "version": "0.1.6", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-valid-domain/-/is-valid-domain-0.1.6.tgz", - "integrity": "sha1-PIVGnSk48XDI+Czm5S34rZ/KgQU=", - "dev": true, - "license": "MIT (https://github.com/miguelmota/is-valid-domain/blob/master/LICENSE)", - "dependencies": { - "punycode": "^2.1.1" - } - }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha1-MniXsmgyo+sRfabCdJLQTKEyWU8=", - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true, - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha1-iDOp2Jq0rN5hiJQr0cU7Y5DtWoo=", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/joi": { - "version": "17.13.8", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/joi/-/joi-17.13.8.tgz", - "integrity": "sha512-iPKOGmiRw1jxf/JOPwxmCcUQAOdF359mdzYiP2DJ+TMX0YK2zjK3D+zYOaGjpumWxOFF/l2xVWjRVK5bGSLdEw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^9.3.0", - "@hapi/topo": "^5.1.0", - "@sideway/address": "^4.1.5", - "@sideway/formula": "^3.0.1", - "@sideway/pinpoint": "^2.0.0" - } - }, - "node_modules/jose": { - "version": "6.2.12", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jose/-/jose-6.2.12.tgz", - "integrity": "sha1-ZWY+FG7dAQuY7Og/gXN9Oqlf0Nc=", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/json-schema-library": { - "version": "9.3.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-schema-library/-/json-schema-library-9.3.5.tgz", - "integrity": "sha1-3WYAIlehNXKs1oGFSgI+HaDoa/o=", - "dev": true, - "license": "MIT", - "dependencies": { - "@sagold/json-pointer": "^5.1.2", - "@sagold/json-query": "^6.1.3", - "deepmerge": "^4.3.1", - "fast-copy": "^3.0.2", - "fast-deep-equal": "^3.1.3", - "smtp-address-parser": "1.0.10", - "valid-url": "^1.0.9" - } - }, - "node_modules/json-schema-ref-resolver": { - "version": "3.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", - "integrity": "sha1-KPakEBIs3pI4dipekpb6o4vihwg=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha1-rnvLNlard6c7pcSb9lTzjmtoYOI=", - "license": "MIT" - }, - "node_modules/json-source-map": { - "version": "0.6.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-source-map/-/json-source-map-0.6.1.tgz", - "integrity": "sha1-4LH29M4Tqa1X4q4WWiTQbmLHmg8=", - "dev": true, - "license": "MIT" - }, - "node_modules/json-with-bigint": { - "version": "3.5.12", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-with-bigint/-/json-with-bigint-3.5.12.tgz", - "integrity": "sha1-n2v3IqmEftcvqbIXsrZhgSJ8RUw=", - "license": "MIT" - }, - "node_modules/jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha1-bNV6sB6bCsB8uEfVPTybbuMfeuI=", - "license": "MIT", - "dependencies": { - "jws": "^4.0.1", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha1-v4F20a0M1y4PP1gzhZWhPhELyAQ=", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jws/-/jws-4.0.1.tgz", - "integrity": "sha1-B+3Bvo+sIOZ3soPs4mFJi9OPBpA=", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jwt-decode": { - "version": "4.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jwt-decode/-/jwt-decode-4.0.0.tgz", - "integrity": "sha1-InA1JCX9QTeFsvrxH251XFFRvUs=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/keytar": { - "version": "7.9.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/keytar/-/keytar-7.9.0.tgz", - "integrity": "sha1-TGIlcI9RtQy/d8Wq6BchlkwpGMs=", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^4.3.0", - "prebuild-install": "^7.0.1" - } - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha1-p5yezIbuHOP6YgbRIWxQHxR/wH4=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ky": { - "version": "1.14.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ky/-/ky-1.14.3.tgz", - "integrity": "sha1-1/zGrJPRWIrM7nLMRNzGz4TAvao=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/ky?sponsor=1" - } - }, - "node_modules/latest-version": { - "version": "9.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/latest-version/-/latest-version-9.0.0.tgz", - "integrity": "sha1-6R7SFuekutxvc7ZsZa20bFjsa6E=", - "dev": true, - "license": "MIT", - "dependencies": { - "package-json": "^10.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/light-my-request": { - "version": "6.6.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/light-my-request/-/light-my-request-6.6.0.tgz", - "integrity": "sha1-yUSHcjI/ZfM3IPtZeceEHxQGCt0=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "dependencies": { - "cookie": "^1.0.1", - "process-warning": "^4.0.0", - "set-cookie-parser": "^2.6.0" - } - }, - "node_modules/light-my-request/node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha1-O7m9/II2nbnC9pyTycPOsxDIizw=", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/light-my-request/node_modules/process-warning": { - "version": "4.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/process-warning/-/process-warning-4.0.1.tgz", - "integrity": "sha1-XB22YAfGfHVuTgnrFwzezhXaMvs=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha1-/ytmwfYybVlRPeJAe/iBQ5gSdxw=", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=", - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=", - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=", - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha1-P727lbRoOsn8eFER55LlWNSr1QM=", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lru-cache": { - "version": "11.5.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lru-cache/-/lru-cache-11.5.3.tgz", - "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha1-oN10voHiqlwvJ+Zc4oNgXuTit/k=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/md5": { - "version": "2.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/md5/-/md5-2.3.0.tgz", - "integrity": "sha1-w9qaaq46MLRreww0m4exENw72k8=", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "charenc": "0.0.2", - "crypt": "0.0.2", - "is-buffer": "~1.1.6" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha1-UoI2KaFN0AyXcPtq1H3GMQ8sH2A=", - "dev": true, - "license": "MIT" - }, - "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime/-/mime-3.0.0.tgz", - "integrity": "sha1-s3RVDco6DBhEOwyVCmpY8ZMc96c=", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha1-u6vNwChZ9JhzAchW4zh85exDv3A=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha1-OBqHG2KnNEUGYK497uRIE/cNlZo=", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha1-ftLCzMyvhNP/y3pptXcR/CCDQBs=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha1-LR1Zr5wbEpgVrMwsRqAipc4fo8k=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha1-/ZVrvgt3JB6fFaxdzLHGOAYJaO8=", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha1-waRk52kzAuCCoHXO4MBXdBrEdyw=", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha1-eTibTrG7LQA6m7qH1JLyvTe9xls=", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha1-fe8D0kMtyuS6HWEURcSDlgYiVfY=", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha1-+hDJEVzG2IZb4iG6R+6b7XhgERM=", - "dev": true, - "license": "MIT" - }, - "node_modules/moo": { - "version": "0.5.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/moo/-/moo-0.5.3.tgz", - "integrity": "sha1-3820D/bxoDw0r133+XF87PqIw4w=", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.1.3.tgz", - "integrity": "sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=", - "license": "MIT" - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha1-E8IsAYf8/MzhRhhEE2NypH3cAn4=", - "dev": true, - "license": "MIT" - }, - "node_modules/nearley": { - "version": "2.20.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/nearley/-/nearley-2.20.1.tgz", - "integrity": "sha1-JGzTPv8NAS+vGX/2d016x4rN1HQ=", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^2.19.0", - "moo": "^0.5.0", - "railroad-diagrams": "^1.0.0", - "randexp": "0.4.6" - }, - "bin": { - "nearley-railroad": "bin/nearley-railroad.js", - "nearley-test": "bin/nearley-test.js", - "nearley-unparse": "bin/nearley-unparse.js", - "nearleyc": "bin/nearleyc.js" - }, - "funding": { - "type": "individual", - "url": "https://nearley.js.org/#give-to-nearley" - } - }, - "node_modules/nearley/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/commander/-/commander-2.20.3.tgz", - "integrity": "sha1-/UhehMA+tIgcIHIrpIA16FMa6zM=", - "dev": true, - "license": "MIT" - }, - "node_modules/node-abi": { - "version": "3.96.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/node-abi/-/node-abi-3.96.0.tgz", - "integrity": "sha1-iwOLxsypfWzsLpqv24G5ANof+A0=", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-addon-api": { - "version": "4.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/node-addon-api/-/node-addon-api-4.3.0.tgz", - "integrity": "sha1-UqGgtHUZPgko6Y4EJqDRJUeCt38=", - "dev": true, - "license": "MIT" - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha1-0PD6bj4twdJ+/NitmdVQvalNGH0=", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha1-t+zR5e1T2o43pV4cImnguX7XSOo=", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/oauth4webapi": { - "version": "3.8.8", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/oauth4webapi/-/oauth4webapi-3.8.8.tgz", - "integrity": "sha1-BbR5dctiHN/V2tNc7Pr0EXDhXHk=", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha1-/tGVyevdt9nkw4Qvk/KBrI2t07g=", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha1-WMjEQRblSEWtV/FKsQsDUzGErD8=", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha1-0Oluu1awdHbfHdnEgG5SN5hcpF4=", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/open/-/open-10.2.0.tgz", - "integrity": "sha1-udhVvgB2IOgLb7BfrJgUH+Yttzw=", - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/openid-client": { - "version": "6.8.8", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/openid-client/-/openid-client-6.8.8.tgz", - "integrity": "sha1-sYRIsUxUaO+It8F1vubYAYPWMxw=", - "dev": true, - "license": "MIT", - "dependencies": { - "jose": "^6.2.12", - "oauth4webapi": "^3.8.8" - }, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ora/-/ora-5.4.1.tgz", - "integrity": "sha1-GyZ4Qmr0rEpQkAjl5KyemVnbnhg=", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/p-event": { - "version": "4.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/p-event/-/p-event-4.2.0.tgz", - "integrity": "sha1-r0sEnIrNka6BCD69Hm9criBEwbU=", - "dev": true, - "license": "MIT", - "dependencies": { - "p-timeout": "^3.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/p-timeout": { - "version": "3.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/p-timeout/-/p-timeout-3.2.0.tgz", - "integrity": "sha1-x+F6vJcdKnli74NiazXWNazyPf4=", - "dev": true, - "license": "MIT", - "dependencies": { - "p-finally": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/package-json": { - "version": "10.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/package-json/-/package-json-10.0.1.tgz", - "integrity": "sha1-5J7ge43mO2OOfxtbs1NzPkKP59c=", - "dev": true, - "license": "MIT", - "dependencies": { - "ky": "^1.2.0", - "registry-auth-token": "^5.0.2", - "registry-url": "^6.0.1", - "semver": "^7.6.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha1-TxRxoBCCeob5TP2bByfjbSZ95QU=", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha1-naGee+6NEt/wUT7Vt2lXeTvC6NQ=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/password-prompt": { - "version": "1.1.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/password-prompt/-/password-prompt-1.1.3.tgz", - "integrity": "sha1-BeU59OfKTWyGXUeTE/EOudtj7l8=", - "dev": true, - "license": "0BSD", - "dependencies": { - "ansi-escapes": "^4.3.2", - "cross-spawn": "^7.0.3" - } - }, - "node_modules/path-expression-matcher": { - "version": "1.6.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", - "integrity": "sha1-VnxzwHGX6dzvJOkO3NxXEFZZkWg=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha1-WB9q3mWMu6ZaDTOA3ndTKVBU83U=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha1-a+DQ7gKhDZ4N56mLrmXhgskGH4U=", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/pem": { - "version": "1.14.8", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pem/-/pem-1.14.8.tgz", - "integrity": "sha1-nEFL7pkbE4okYX9CMFnoCdAapyA=", - "dev": true, - "license": "MIT", - "dependencies": { - "es6-promisify": "^7.0.0", - "md5": "^2.3.0", - "os-tmpdir": "^1.0.2", - "which": "^2.0.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/pino": { - "version": "10.3.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pino/-/pino-10.3.1.tgz", - "integrity": "sha1-ZVLI+NhIGETJ5FLnvwvpC/8ZOc4=", - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^3.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^4.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha1-sh5fM6KX6MTJFcYrPOXdSoelLCM=", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha1-p7DNZSJfKeklQOeFO9c7B0eYk/w=", - "license": "MIT" - }, - "node_modules/pkijs": { - "version": "3.4.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pkijs/-/pkijs-3.4.1.tgz", - "integrity": "sha512-Oo/NZcSWccq8KyoG7gLE9fnltgHns+pNCjCAp/WmjsUySi+sX7y4z4Xqu4fVb42CDHzRPl33fjzT15V1wvcyhA==", - "dev": true, - "license": "BSD-3-Clause", - "workspaces": [ - "website" - ], - "dependencies": { - "@noble/hashes": "1.8.0", - "asn1js": "^3.0.6", - "bytestreamjs": "^2.0.1", - "pvtsutils": "^1.3.6", - "pvutils": "^1.1.3", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha1-1jCrrSsUdEPyCiEpF76uaLgJLuw=", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/process-warning": { - "version": "5.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/process-warning/-/process-warning-5.1.0.tgz", - "integrity": "sha1-DqCUsYthD1lCQ+/FheIPLb7lVdw=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha1-e1fnOzpIAprRDr1E90sBcipMsGk=", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/proto-list": { - "version": "1.2.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", - "dev": true, - "license": "ISC" - }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha1-p0h1aK2tV3z6qn6IxJyrOrMIGro=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pump/-/pump-3.0.4.tgz", - "integrity": "sha1-HzE0MFJ/qLkFYi69Iv4UROdXqzw=", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha1-AnQi4vrsCyXhVJw+G9gwm5EztuU=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/pupa": { - "version": "3.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pupa/-/pupa-3.3.0.tgz", - "integrity": "sha1-vEA2+eiSDAitRyvBj7YABny4OBA=", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-goat": "^4.0.0" - }, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pvtsutils": { - "version": "1.3.6", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pvtsutils/-/pvtsutils-1.3.6.tgz", - "integrity": "sha1-7EbjTbdCK55P3FSQV4wYg2V9YAE=", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" - } - }, - "node_modules/pvutils": { - "version": "1.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pvutils/-/pvutils-1.2.0.tgz", - "integrity": "sha1-S1SHqczNUtJ1sFOHdXkKPKmCvCI=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha1-k+9t2NNFPLx5cN1hT61MWVTWtac=", - "license": "MIT" - }, - "node_modules/railroad-diagrams": { - "version": "1.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", - "integrity": "sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/randexp": { - "version": "0.4.6", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/randexp/-/randexp-0.4.6.tgz", - "integrity": "sha1-6YatXl4x2uE93W97MBmqfIf2DKM=", - "dev": true, - "license": "MIT", - "dependencies": { - "discontinuous-range": "1.0.0", - "ret": "~0.1.10" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/randexp/node_modules/ret": { - "version": "0.1.15", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ret/-/ret-0.1.15.tgz", - "integrity": "sha1-uKSCXVvbH8P29Twrwz+BOIaBx7w=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha1-PPNwI9GZ4cJNGlW4SADC8+ZGgDE=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rc/-/rc-1.2.8.tgz", - "integrity": "sha1-zZJL9SAKB1uDwYjNa54hG3/A0+0=", - "dev": true, - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha1-VqmzbqllwAxak+8x6xEaDxEFaWc=", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha1-IJYy3qGBC+KuBjpqwIT+5+M/ung=", - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha1-QAyEW2y6h6IfLGXErrFY9PpNnFs=", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/registry-auth-token": { - "version": "5.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/registry-auth-token/-/registry-auth-token-5.1.1.tgz", - "integrity": "sha1-8f9pyOSS5+3uBxELR1LdCoqoKFM=", - "dev": true, - "license": "MIT", - "dependencies": { - "@pnpm/npm-conf": "^3.0.2" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/registry-url": { - "version": "6.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/registry-url/-/registry-url-6.0.1.tgz", - "integrity": "sha1-BW2TQ2gPL2RAADKx4Zn6ppIobFg=", - "dev": true, - "license": "MIT", - "dependencies": { - "rc": "1.2.8" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha1-iaf92TgmEmcxjq/hT5wy5ZjDaQk=", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", - "dev": true, - "license": "MIT" - }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha1-OfZ8VLOnpYzqUjbZXPADQjljH34=", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ret": { - "version": "0.5.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ret/-/ret-0.5.0.tgz", - "integrity": "sha1-MKTTin5wS9ltxf/L584qknTEHJU=", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha1-D+E7lSLhRz9RtVjueW4I8R+bSJ8=", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha1-d492xPtzHZNBTo+SX77PZMzn9so=", - "license": "MIT" - }, - "node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha1-I7mEPT3JLbcfluGizpLjn9KoIhw=", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha1-6D46fj8wCzTLnYf2FfoMvzV2kO4=", - "dev": true, - "license": "MIT" - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.1.7", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-2.1.7.tgz", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/glob/-/glob-10.5.0.tgz", - "integrity": "sha1-jsA1WRnNMzjChCiiPU8k7MX+c4w=", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha1-QQ/IoXtw5ZgBPfJXwkRrfzOD8Rk=", - "dev": true, - "license": "ISC" - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha1-mwy5/LeAh/b9fqur4lEcTT1gV04=", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha1-eWCmaIiFlKByCxKpEdGnQqufEdI=", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha1-Lp5UxGZOwxBsW1Yw4knT1llcSRE=", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha1-lVvEc+2K8RoAKivlIHG/R1Y4YHs=", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha1-Hq+fqb2x/dTsdfWPnNtOa3gn7sY=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha1-On1WtVnWy8PrUSMlJE5hmmXGxSU=", "license": "MIT" }, - "node_modules/safe-regex2": { - "version": "5.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safe-regex2/-/safe-regex2-5.1.1.tgz", - "integrity": "sha1-pfOm41uNhND0H6Iu/VttMLNnu8c=", + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha1-5SihGmGW+lTt1+UTacMHZOCDnRM=", "funding": [ { "type": "github", @@ -5644,25 +900,18 @@ ], "license": "MIT", "dependencies": { - "ret": "~0.5.0" - }, - "bin": { - "safe-regex2": "bin/safe-regex2.js" - } - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha1-TKL444XygxxDKnGbEIo7969Cod0=", - "license": "MIT", - "engines": { - "node": ">=10" + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" } }, - "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha1-Txq0HGehNJfqG5Exu0GDoihlR3w=", + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-uri/-/fast-uri-4.2.1.tgz", + "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==", "funding": [ { "type": "github", @@ -5675,975 +924,565 @@ ], "license": "BSD-3-Clause" }, - "node_modules/selfsigned": { - "version": "5.5.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/selfsigned/-/selfsigned-5.5.0.tgz", - "integrity": "sha1-TJq3x8nzXxj7apiCwlPrDmvWVXs=", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/x509": "^1.14.2", - "pkijs": "^3.3.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/semver/-/semver-7.8.5.tgz", - "integrity": "sha1-ObZGA33VDBT7RR5+TKxY7YuGP2k=", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/send/-/send-0.19.2.tgz", - "integrity": "sha1-WbwNobTqetQnNv1kKxxClOEU/yk=", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-2.6.9.tgz", - "integrity": "sha1-XRKFFd8TT/Mn6QpMk/Tgd6U2NB8=", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true, - "license": "MIT" - }, - "node_modules/send/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime/-/mime-1.6.0.tgz", - "integrity": "sha1-Ms2eXGRVO9WNGaVor0Uqz/BJgbE=", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha1-qXt02VV3hYPzhipPC4QetNXXjPk=", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha1-zNCGc6muXS5E6iot4lCJ5nx+32g=", - "license": "MIT" - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha1-ZsmiSnP5/CjL5msJ/tPTPcrxtCQ=", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha1-zNCvT4g1+9wmW4JGGq8MNmY/NOo=", - "dev": true, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha1-ptJJN7T8b3kbTuMdy29Trq+4n1M=", "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha1-rhbxZE2HPsrYQ7AwexQzYtTEIXI=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.12.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shell-quote/-/shell-quote-1.12.0.tgz", - "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "fast-decode-uri-component": "^1.0.1" } }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha1-qaF2f4r4QVURTqq9c/mSc8j1mtk=", - "dev": true, - "license": "ISC" - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha1-9Gl2CCujXCJj8cirXt/ibEHJVS8=", - "dev": true, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "url": "https://github.com/sponsors/fastify" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "opencollective", + "url": "https://opencollective.com/fastify" } ], - "license": "MIT" + "license": "BSD-3-Clause" }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha1-SjnbVJKHyXnTUhEvoD/Zn9a8NUM=", - "dev": true, + "node_modules/fastify": { + "version": "5.12.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fastify/-/fastify-5.12.5.tgz", + "integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==", "funding": [ { "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "url": "https://github.com/sponsors/fastify" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "opencollective", + "url": "https://opencollective.com/fastify" } ], "license": "MIT", "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" } }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha1-E01oEpd1ZDfMBcoBNw06elcQde0=", - "dev": true, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha1-fNZZxqrV878HtMWSBUEjUaR9Ocg=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT" }, - "node_modules/smtp-address-parser": { - "version": "1.0.10", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/smtp-address-parser/-/smtp-address-parser-1.0.10.tgz", - "integrity": "sha1-n8TtYCHxPcPY9ZHgrQ1QRUBzAl4=", - "dev": true, - "license": "MIT", - "dependencies": { - "nearley": "^2.20.1" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha1-KFmCUN9ImcCsVy1+LwRgaQumoDA=", - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/spawn-command": { - "version": "0.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/spawn-command/-/spawn-command-0.0.2.tgz", - "integrity": "sha1-lUThpDygRfhTGqwaSMspva5iM44=", - "dev": true - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/split2/-/split2-4.2.0.tgz", - "integrity": "sha1-ycWSCQTRSLqwufZxRfJFqGqtv6Q=", + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha1-erhzHmR7VqvP7umX2uMzyj7h0E0=", "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha1-j3XuzvdlteHPzcCA2llAntQk44I=", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha1-QvEUWUpGzxqOMLCoT1bHjD7awh4=", - "dev": true, - "license": "MIT", "dependencies": { - "safe-buffer": "~5.2.0" + "reusify": "^1.0.4" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha1-JpxxF9J7Ba0uU2gwqOyJXvnG0BA=", - "dev": true, + "node_modules/find-my-way": { + "version": "9.9.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/find-my-way/-/find-my-way-9.9.0.tgz", + "integrity": "sha1-k8RD1/3z4F3YCYVDAjY9iodt8qs=", "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=20" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha1-JpxxF9J7Ba0uU2gwqOyJXvnG0BA=", - "dev": true, - "license": "MIT", + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/glob/-/glob-13.0.6.tgz", + "integrity": "sha1-B4ZmVmpCUUfMrPvS4zLetmor5x0=", + "license": "BlueOak-1.0.0", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" + "node": "18 || 20 || >=22" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", - "dev": true, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha1-NtL2W8kJyHkAGN02+02T2myq4Gs=", "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { - "node": ">=8" + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha1-ibhS+y/L6Tb29LMYevsKEsGrWK0=", - "dev": true, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha1-D6LGT5MpF8NDOg3tVTY6rjdBa3w=", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha1-fUtsOfk5L7Yc+AfebkJeIsEOBh8=", "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 10" } }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", - "dev": true, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jose/-/jose-6.2.12.tgz", + "integrity": "sha1-ZWY+FG7dAQuY7Og/gXN9Oqlf0Nc=", "license": "MIT", - "engines": { - "node": ">=0.10.0" + "funding": { + "url": "https://github.com/sponsors/panva" } }, - "node_modules/strnum": { - "version": "2.4.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strnum/-/strnum-2.4.2.tgz", - "integrity": "sha1-r0OrUaBtBCJwI/wuQsoikhTsEPA=", + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha1-KPakEBIs3pI4dipekpb6o4vihwg=", "funding": [ { "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" } ], "license": "MIT", "dependencies": { - "anynum": "^1.0.1" - } - }, - "node_modules/stubborn-fs": { - "version": "2.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/stubborn-fs/-/stubborn-fs-2.0.0.tgz", - "integrity": "sha1-YodQ+BxRxEwE71D8cO1NHK6k8ek=", - "dev": true, - "license": "MIT", - "dependencies": { - "stubborn-utils": "^1.0.1" + "dequal": "^2.0.3" } }, - "node_modules/stubborn-utils": { - "version": "1.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/stubborn-utils/-/stubborn-utils-1.0.2.tgz", - "integrity": "sha1-DZxYq1UPQJNiNQVsfqb+vZJcTUE=", - "dev": true, - "license": "MIT" - }, - "node_modules/sudo-prompt": { - "version": "8.2.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sudo-prompt/-/sudo-prompt-8.2.5.tgz", - "integrity": "sha1-zF7zdpoTS7lLJKYxzAlijU1TYD4=", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha1-rnvLNlard6c7pcSb9lTzjmtoYOI=", "license": "MIT" }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha1-G33NyzK4E4gBs+R4umpRyqiWSNo=", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha1-M+nClBPc4MWK2n/3fbTlowr//nA=", - "dev": true, - "license": "MIT", + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha1-yUSHcjI/ZfM3IPtZeceEHxQGCt0=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" } }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha1-rK2EwoQTawYNw/qmRHSqmuvXcoc=", - "dev": true, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha1-O7m9/II2nbnC9pyTycPOsxDIizw=", "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, "engines": { - "node": ">=6" - } - }, - "node_modules/thread-stream": { - "version": "4.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/thread-stream/-/thread-stream-4.2.0.tgz", - "integrity": "sha1-BUBj6TuqsiNj0Ft4TWx+Q5Iwysc=", - "license": "MIT", - "dependencies": { - "real-require": "^1.0.0" + "node": ">=18" }, - "engines": { - "node": ">=20" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/thread-stream/node_modules/real-require": { - "version": "1.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/real-require/-/real-require-1.0.0.tgz", - "integrity": "sha1-5PCjc36AYMUTtgwmUsWLjvVryKk=", + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha1-XB22YAfGfHVuTgnrFwzezhXaMvs=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT" }, - "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha1-JvTbEdFgHOgBLcuKeY7OHAapkFk=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/toad-cache": { - "version": "3.7.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/toad-cache/-/toad-cache-3.7.4.tgz", - "integrity": "sha1-IR5yYFu3hi38aOHA350P49wt7Ac=", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha1-O+NDIaiKgg7RvYDfqjPkefu43TU=", - "license": "MIT", + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=0.6" + "node": "20 || >=22" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", - "dev": true, - "license": "MIT" - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha1-TKCakJLIi3OnzcXooBtQeweQoMw=", - "dev": true, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime/-/mime-3.0.0.tgz", + "integrity": "sha1-s3RVDco6DBhEOwyVCmpY8ZMc96c=", "license": "MIT", "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha1-YS7+TtI11Wfoq6Xypfq3AoCt6D8=", - "license": "0BSD" - }, - "node_modules/tsyringe": { - "version": "4.10.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tsyringe/-/tsyringe-4.10.0.tgz", - "integrity": "sha1-0MlYFdWERkIUBgKF6qrdlKoDKZw=", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^1.9.3" + "mime": "cli.js" }, "engines": { - "node": ">= 6.0.0" + "node": ">=10.0.0" } }, - "node_modules/tsyringe/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha1-zy04vcNKE0vK8QkcQfZhni9nLQA=", - "dev": true, - "license": "0BSD" - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "dev": true, - "license": "Apache-2.0", + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha1-/ZVrvgt3JB6fFaxdzLHGOAYJaO8=", + "license": "BlueOak-1.0.0", "dependencies": { - "safe-buffer": "^5.0.1" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha1-0mCiSwGYQ24TP6JqUkptZfo7Ljc=", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha1-W09Z4VMQqxeiFvXWz1PuR27eZw8=", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha1-eTibTrG7LQA6m7qH1JLyvTe9xls=", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=14.17" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha1-aR0ArzkJvpOn+qE75hs6W1DvEss=", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", - "dev": true, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha1-/tGVyevdt9nkw4Qvk/KBrI2t07g=", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=14.0.0" } }, - "node_modules/update-notifier": { - "version": "7.3.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/update-notifier/-/update-notifier-7.3.1.tgz", - "integrity": "sha1-Sa8a1qz6DqAcDQ88BAR8FU6tcJY=", - "dev": true, - "license": "BSD-2-Clause", + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha1-a+DQ7gKhDZ4N56mLrmXhgskGH4U=", + "license": "BlueOak-1.0.0", "dependencies": { - "boxen": "^8.0.1", - "chalk": "^5.3.0", - "configstore": "^7.0.0", - "is-in-ci": "^1.0.0", - "is-installed-globally": "^1.0.0", - "is-npm": "^6.0.0", - "latest-version": "^9.0.0", - "pupa": "^3.1.0", - "semver": "^7.6.3", - "xdg-basedir": "^5.1.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=18" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/yeoman/update-notifier?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/update-notifier/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha1-sSOLbiPqM3r3HH+KKV21rwwViuo=", - "dev": true, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pino/-/pino-10.3.1.tgz", + "integrity": "sha1-ZVLI+NhIGETJ5FLnvwvpC/8ZOc4=", "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "bin": { + "pino": "bin.js" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha1-sh5fM6KX6MTJFcYrPOXdSoelLCM=", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha1-p7DNZSJfKeklQOeFO9c7B0eYk/w=", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha1-DqCUsYthD1lCQ+/FheIPLb7lVdw=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha1-k+9t2NNFPLx5cN1hT61MWVTWtac=", "license": "MIT" }, - "node_modules/valid-url": { - "version": "1.0.9", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/valid-url/-/valid-url-1.0.9.tgz", - "integrity": "sha1-HBRHm0DxOXp1eC8RXkCGRHQzogA=", - "dev": true - }, - "node_modules/wait-on": { - "version": "7.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wait-on/-/wait-on-7.2.0.tgz", - "integrity": "sha1-12sg7T/B4r68BR+uXB/5O+eJKSg=", - "dev": true, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha1-IJYy3qGBC+KuBjpqwIT+5+M/ung=", "license": "MIT", - "dependencies": { - "axios": "^1.6.1", - "joi": "^17.11.0", - "lodash": "^4.17.21", - "minimist": "^1.2.8", - "rxjs": "^7.8.1" - }, - "bin": { - "wait-on": "bin/wait-on" - }, "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", - "dev": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" + "node": ">= 12.13.0" } }, - "node_modules/when-exit": { - "version": "2.1.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/when-exit/-/when-exit-2.1.5.tgz", - "integrity": "sha1-U/pP+iukx5IhP7ZhfrfQjw3LGp8=", - "dev": true, - "license": "MIT" - }, - "node_modules/which": { + "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/which/-/which-2.0.2.tgz", - "integrity": "sha1-fGqN0KY2oDJ+ELWckobu6T8/UbE=", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha1-iaf92TgmEmcxjq/hT5wy5ZjDaQk=", + "license": "MIT", "engines": { - "node": ">= 8" + "node": ">=0.10.0" } }, - "node_modules/widest-line": { - "version": "5.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/widest-line/-/widest-line-5.0.0.tgz", - "integrity": "sha1-t0gmoeSAeDNF8M2QYbSXU8nacNA=", - "dev": true, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ret/-/ret-0.5.0.tgz", + "integrity": "sha1-MKTTin5wS9ltxf/L584qknTEHJU=", "license": "MIT", - "dependencies": { - "string-width": "^7.0.0" - }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=10" } }, - "node_modules/widest-line/node_modules/ansi-regex": { - "version": "6.4.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-6.4.0.tgz", - "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", - "dev": true, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha1-D+E7lSLhRz9RtVjueW4I8R+bSJ8=", "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "iojs": ">=1.0.0", + "node": ">=0.10.0" } }, - "node_modules/widest-line/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha1-vz1uj3+P0ipl2XA0dbwBRzV6aw0=", - "dev": true, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha1-d492xPtzHZNBTo+SX77PZMzn9so=", "license": "MIT" }, - "node_modules/widest-line/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha1-tbuOIWXOJ11NQ0dt0nAK2Qkdttw=", - "dev": true, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha1-pfOm41uNhND0H6Iu/VttMLNnu8c=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" + "ret": "~0.5.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "safe-regex2": "bin/safe-regex2.js" } }, - "node_modules/widest-line/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha1-0iomlSKDamJ6+NBLXD/Sx/o+MuM=", - "dev": true, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha1-TKL444XygxxDKnGbEIo7969Cod0=", "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=10" } }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha1-lWgy3qlJQwbm0gnrhxZDu4c9fJg=", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha1-Txq0HGehNJfqG5Exu0GDoihlR3w=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha1-Z+FFz/UQpqaYS98RUpEdadLrnkM=", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/semver/-/semver-7.8.5.tgz", + "integrity": "sha1-ObZGA33VDBT7RR5+TKxY7YuGP2k=", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.4.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-6.4.0.tgz", - "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha1-wETV3MUhoHZBNHJZehrLHxA8QEE=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha1-vz1uj3+P0ipl2XA0dbwBRzV6aw0=", - "dev": true, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha1-zNCGc6muXS5E6iot4lCJ5nx+32g=", "license": "MIT" }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha1-tbuOIWXOJ11NQ0dt0nAK2Qkdttw=", - "dev": true, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha1-ZsmiSnP5/CjL5msJ/tPTPcrxtCQ=", + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha1-KFmCUN9ImcCsVy1+LwRgaQumoDA=", "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/split2/-/split2-4.2.0.tgz", + "integrity": "sha1-ycWSCQTRSLqwufZxRfJFqGqtv6Q=", + "license": "ISC", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 10.x" } }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha1-0iomlSKDamJ6+NBLXD/Sx/o+MuM=", - "dev": true, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha1-j3XuzvdlteHPzcCA2llAntQk44I=", "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">= 0.8" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true, - "license": "ISC" - }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha1-h4PU32cdTVA2W+LuTHGRegVXuqs=", + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha1-BUBj6TuqsiNj0Ft4TWx+Q5Iwysc=", "license": "MIT", "dependencies": { - "is-wsl": "^3.1.0" + "real-require": "^1.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=20" } }, - "node_modules/xdg-basedir": { - "version": "5.1.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/xdg-basedir/-/xdg-basedir-5.1.0.tgz", - "integrity": "sha1-HvuhlCXnO+G8byps61Kj0siEwMk=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha1-5PCjc36AYMUTtgwmUsWLjvVryKk=", + "license": "MIT" }, - "node_modules/xml-naming": { - "version": "0.3.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/xml-naming/-/xml-naming-0.3.0.tgz", - "integrity": "sha1-RsHhi/4oWEeZgt0qzPNNFudJ7aI=", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha1-IR5yYFu3hi38aOHA350P49wt7Ac=", "license": "MIT", "engines": { - "node": ">=16.0.0" + "node": ">=20" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha1-f0k00PfKjFb5UxSTndzS3ZHOHVU=", - "dev": true, - "license": "ISC", + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha1-O+NDIaiKgg7RvYDfqjPkefu43TU=", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=0.6" } }, - "node_modules/yaml": { - "version": "2.9.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yaml/-/yaml-2.9.1.tgz", - "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha1-W09Z4VMQqxeiFvXWz1PuR27eZw8=", "dev": true, - "license": "ISC", + "license": "Apache-2.0", "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yargs": { - "version": "17.7.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yargs/-/yargs-17.7.3.tgz", - "integrity": "sha1-d53/5ryv7FlqcXLpgyiaWIZH+qo=", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=12" + "node": ">=14.17" } }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha1-kJa87r+ZDSG7MfqVFuDt4pSnfTU=", + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha1-aR0ArzkJvpOn+qE75hs6W1DvEss=", "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } + "license": "MIT" }, "node_modules/zod": { "version": "4.6.5", diff --git a/package.json b/package.json index f063331..3aa2925 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sql-apps", - "version": "0.1.2", + "version": "0.2.0", "private": true, "type": "module", "engines": { @@ -28,27 +28,19 @@ "plugin:check": "node scripts/check-plugin.mjs", "plugin:install": "node scripts/install-plugin.mjs", "infra:build": "az bicep build --file infra/main.bicep --outfile dist/main.json", - "infra:test-demo": "node --test tests/demo-infra.test.mjs tests/role-based-infra.test.mjs", + "infra:test": "node --test tests/role-based-infra.test.mjs", "sql:build": "dotnet build sql/database.sqlproj --output dist/sql", "sql:tools": "dotnet tool restore", "dab:validate": "node dist/src/validate-dab.js" }, "dependencies": { - "@azure/functions": "4.16.5", - "@azure/identity": "4.13.3", - "@azure/keyvault-secrets": "4.11.2", "@azure/msal-browser": "5.25.0", - "@azure/storage-blob": "12.34.0", - "@azure/storage-queue": "12.32.0", "@fastify/static": "10.1.5", - "@opentelemetry/api": "1.9.1", - "@opentelemetry/sdk-trace-base": "2.11.0", "fastify": "5.12.5", "jose": "6.2.12", "zod": "4.6.5" }, "devDependencies": { - "@azure/static-web-apps-cli": "2.0.10", "@types/node": "22.20.4", "ajv": "8.20.0", "ajv-formats": "3.0.1", diff --git a/packages/sql-apps/package.json b/packages/sql-apps/package.json index 140bf92..46b7b0e 100644 --- a/packages/sql-apps/package.json +++ b/packages/sql-apps/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/sql-apps", - "version": "0.1.2", + "version": "0.2.0", "type": "module", "license": "MIT", "engines": { @@ -19,15 +19,8 @@ "assets" ], "dependencies": { - "@azure/functions": "4.16.5", - "@azure/identity": "4.13.3", - "@azure/keyvault-secrets": "4.11.2", "@azure/msal-browser": "5.25.0", - "@azure/storage-blob": "12.34.0", - "@azure/storage-queue": "12.32.0", "@fastify/static": "10.1.5", - "@opentelemetry/api": "1.9.1", - "@opentelemetry/sdk-trace-base": "2.11.0", "esbuild": "0.28.2", "fastify": "5.12.5", "jose": "6.2.12", diff --git a/plugin.json b/plugin.json index e79e398..86781a5 100644 --- a/plugin.json +++ b/plugin.json @@ -1,11 +1,11 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "sql-apps", - "version": "0.1.2", - "description": "Build this independent Azure-native stack with Azure CLI, Bicep, Entra ID, Azure SQL, Blob Storage, and Functions.", + "version": "0.2.0", + "description": "Build and validate applications on Azure SQL Database and Data API Builder; use maintained Azure skills for infrastructure, cost, deployment, and operations.", "author": { "name": "Microsoft SQL team (mssql)", "url": "https://github.com/microsoft" }, "homepage": "https://github.com/microsoft/sql-apps", "repository": "https://github.com/microsoft/sql-apps", "license": "MIT", - "keywords": ["azure", "bicep", "typescript", "data-api-builder"] + "keywords": ["azure-sql", "data-api-builder", "typescript"] } diff --git a/plugins/sql-apps/plugin.json b/plugins/sql-apps/plugin.json index fc17fa7..6635192 100644 --- a/plugins/sql-apps/plugin.json +++ b/plugins/sql-apps/plugin.json @@ -1,11 +1,11 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "sql-apps", - "version": "0.1.2", - "description": "Build and design standalone SQL Apps applications, and start, inspect, troubleshoot and validate them locally.", + "version": "0.2.0", + "description": "Build and validate applications on Azure SQL Database and Data API Builder; route general Azure work to maintained Azure skills.", "author": { "name": "Microsoft SQL team (mssql)", "url": "https://github.com/microsoft" }, "homepage": "https://github.com/microsoft/sql-apps", "repository": "https://github.com/microsoft/sql-apps", "license": "MIT", - "keywords": ["azure", "local-development", "azure-sql", "data-api-builder", "zero-dimes", "ui-design"] + "keywords": ["azure-sql", "data-api-builder", "local-development", "application-foundation"] } diff --git a/plugins/sql-apps/scripts/sql-apps.mjs b/plugins/sql-apps/scripts/sql-apps.mjs index 2c98ba8..83c7be9 100644 --- a/plugins/sql-apps/scripts/sql-apps.mjs +++ b/plugins/sql-apps/scripts/sql-apps.mjs @@ -170,11 +170,11 @@ export async function status(fetcher = fetch, origins = { app: 'http://127.0.0.1 } } return { services, healthy: services.every(service => service.healthy), - scope: 'HTTP liveness/data readiness only; not worker, storage, SQL authorization or cloud acceptance' }; + scope: 'HTTP liveness/data readiness only; not SQL authorization or cloud acceptance' }; } -const commands = new Set(['app', 'serve', 'serve-sql', 'role-based-app', 'role-based-serve', 'services', 'stop-services', 'maintain', - 'start-sql', 'verify', 'init', 'test', 'data', 'api-test', 'app-test', 'app-check', 'services-test', 'stop', +const commands = new Set(['app', 'serve', 'role-based-app', 'role-based-serve', + 'start-sql', 'verify', 'init', 'test', 'data', 'app-test', 'app-check', 'stop', 'workspace-plan', 'workspace-init', 'recover-sql', 'selected-app', 'selected-serve', 'selected-stop', 'selected-test']); const consentCommands = new Set(['consent-plan', 'consent-status', 'consent-grant', 'consent-grant-once', 'consent-terms', 'consent-revoke', 'consent-run']); @@ -190,9 +190,9 @@ export async function main(args = process.argv.slice(2)) { if (!journey && !consentCommands.has(command) && args.length > 2) throw new Error('Expected a command and its optional selection'); if (command === 'help') { console.log('SQL Apps local plugin: projects | home | workspace-check | guide | guide-save | status | setup-check [existing-sql-container] | ' + [...commands].join(' | ')); - console.log('role-based-setup-check [existing-sql-container] checks only role-based-data service ports before build. Local commands use the configured checkout; no cloud deployment command is exposed.'); + console.log('role-based-setup-check [existing-sql-container] is an alias of setup-check. Local commands use the configured checkout; no cloud deployment command is exposed.'); console.log('Pre-build consent: consent-plan|consent-status|consent-run [selection] | consent-grant|consent-grant-once | consent-terms | consent-revoke [scope|terms]. Record only explicitly approved scopes.'); - console.log('Pre-build journey: setup plan|status|run|resume --profile foundation|role-based-data [--workspace ] [--json]; setup approval|approve --profile [--workspace ] [--approval ] is the one local approval; setup install-plan | setup install . Install only after explicit approval.'); + console.log('Pre-build journey: setup plan|status|run|resume [--workspace ] [--json]; setup approval|approve [--workspace ] [--approval ] is the one local approval; setup install-plan | setup install . Install only after explicit approval.'); return; } if (!['home', 'workspace-check', 'guide', 'guide-save', 'status', 'setup-check', 'role-based-setup-check', 'setup'].includes(command) && !commands.has(command) && !consentCommands.has(command)) throw new Error(`Unsupported plugin command: ${command}`); diff --git a/plugins/sql-apps/skills/sql-apps-application/SKILL.md b/plugins/sql-apps/skills/sql-apps-application/SKILL.md index 49bf531..27c8e0a 100644 --- a/plugins/sql-apps/skills/sql-apps-application/SKILL.md +++ b/plugins/sql-apps/skills/sql-apps-application/SKILL.md @@ -22,14 +22,13 @@ Read the saved brief with `guide`, then the current request. Treat the requested - users and access; - records and required fields; - actions and validation; -- needed capabilities; - the requested outcome. Read `docs/guides/build-your-app.md` only when the scope is not settled. Read the relevant part of `docs/maintainers/application-boundary.md` before implementing its contract. Summarize new decisions and explicit exclusions before editing; do not repeat settled discovery. Keep `application.json` named for the app with `selectedExamples: []`. Do not copy an example, scaffold another runtime or add capabilities outside the agreed scope. -Choose `role-based-data` for SQL/DAB/browser-only apps and `foundation` only when files or background jobs are required. State any unsupported local or cloud combination instead of silently substituting a profile. +SQL Apps has one profile, `role-based-data` (SQL, DAB and the browser gateway); the saved brief's capabilities are exactly `["data"]`. If the app needs file storage or background jobs, say so and route that part to the maintained Azure skills (for example `azure-storage`, `azure-prepare`, `azure-deploy`) instead of adding it to SQL Apps. ## Implement the slice diff --git a/plugins/sql-apps/skills/sql-apps-cloud-preview/SKILL.md b/plugins/sql-apps/skills/sql-apps-cloud-preview/SKILL.md index 30f9330..3bf50e5 100644 --- a/plugins/sql-apps/skills/sql-apps-cloud-preview/SKILL.md +++ b/plugins/sql-apps/skills/sql-apps-cloud-preview/SKILL.md @@ -1,52 +1,35 @@ --- name: sql-apps-cloud-preview -description: "Use only when the user asks to share SQL Apps, review Azure costs or prerequisites, prepare a what-if, or deploy through the project's existing Azure orchestration." +description: "Use when a user wants to share a SQL Apps project or asks about cloud readiness; hand generic Azure planning, cost, validation, deployment, and diagnostics to maintained Azure skills." --- -# Prepare SQL Apps for Azure +# Share a SQL Apps project -Sharing is optional. Resolve `../../scripts/sql-apps.mjs` from this installed skill, run `home`, then confirm the requested application checkout with `workspace-check`. Historical local evidence is not deployment consent or cloud readiness. +Sharing is optional. Confirm the application checkout and preserve the SQL Apps contract: Azure SQL Database is the data store, DAB is the application API, and the user must explicitly request Azure work. -Load only the material needed for the request: +Resolve `../../scripts/sql-apps.mjs` from this installed skill, run `home`, and confirm the requested project with `workspace-check` before reading or testing that project. -- For a cost review, read `docs/guides/costs.md` and the selected profile's cost reference. -- For sharing options or prerequisites, read `docs/guides/sharing.md`. -- For deployment, read `docs/reference/deployment.md` and the relevant part of `docs/maintainers/operations.md`. +## Route cloud lifecycle work -Use the existing Azure CLI and Bicep orchestration; do not invent commands or another deployment system. +Use maintained skills for generic Azure work rather than extending SQL Apps' custom Azure CLI/Bicep orchestration: -## Select the exact profile +- Use `build-app-on-azure-sql` to sequence Azure SQL identity, schema, API, and deployment decisions. +- Use `azure-app-onboard` when the target architecture or service choice is open. +- Use `azure-prepare` to create the Azure deployment plan and infrastructure. +- Use `azure-validate` before any deployment. +- Use `azure-deploy` to deploy the reviewed plan. +- Use the `azure-cost` plugin for forecasts, estimates, actual cost analysis, optimization, and governance. +- Use `azure-diagnostics` for Azure production incidents. +- Use `github-actions-for-sql` for SQL-aware CI pipelines. -Keep cloud preparation tied to the agreed application: +If the Azure Skills plugin or a specialized skill is unavailable, explain the prerequisite and use the project's documented legacy route only when the user explicitly requests that route. Do not implement a parallel generic Azure service, cost, deployment, or diagnostics workflow in SQL Apps. -- Anonymous public demo: synthetic data only; guided deployment remains incomplete. -- Authenticated full foundation: Functions, storage, Key Vault and private networking remain included. -- `role-based-data`: SQL/DAB/browser with matching role assignment and paid private SQL infrastructure. +## Preserve SQL Apps-specific requirements -Do not substitute one profile for another. State unsupported combinations as gaps. +Before handing off, record the `role-based-data` profile, SQL/DAB configuration, identity and application-role contract, private-network requirements, database grants, and acceptance criteria. `npm run azure -- grants ` writes the exact DAB stored-procedure `EXECUTE` grants to `dist/sql/role-based-grants.sql` for the maintained route to apply. File storage or background jobs are not part of SQL Apps; route them to maintained skills such as `azure-storage`. Treat deployment output and infrastructure validation as necessary, not as proof that the SQL/DAB application works. -Run the profile's offline cost command with an explicit configuration. `demo-cost` applies only to the synthetic demo; `role-based-cost` applies to role-based data. Free allowances are not guaranteed zero cost. Unknown prices remain unknown, budgets are alerts rather than caps, and saved preferences are not spending approval. +After deployment, use `sql-apps-validation` for the application-specific acceptance: live sign-in, expected DAB role forwarding, least-privilege procedure access, two-user isolation where required, and a browser write followed by SQL persistence and reload. Report preparation, deployment, and application acceptance as separate results. -For new demo SQL, use the explicitly approved `free-paused` or `paid-reviewed` policy. Free-paused must fail when unavailable; never retry as paid or enable overage automatically. +## Existing SQL Apps deployment commands -## Review before writes - -Before any cloud mutation, present: - -- subscription, tenant, resource group and region; -- application profile, identities and access model; -- images and billable resources; -- public exposure and data sensitivity; -- what-if result, recurring cost assumptions and cleanup path. - -Preparation and read-only discovery are not write approval. Existing matching resources are collision information, not permission to reuse, rename, delete or replace the new application. An email address is not a tenant ID. - -Use `npm run azure -- help` for the implemented surface. The project `init` command derives identity settings after approved sign-in; ask only for placement values it cannot derive. `deploy-all` may run its printed pending list only after explicit approval of that exact list and costs. Role assignment and smoke tests remain separately authorized. - -Keep secrets out of chat, committed files and client configuration. Do not change subscription defaults, register providers, publish SQL, push images, create identities or assign roles without the matching approval. - -## Prove the deployed result - -Compilation, what-if, schema validation and mocks do not prove deployment. Verify live sign-in, intended role forwarding, two-user isolation where required, function identity for selected services, and the real application workflow. Report preparation, deployed resources and live acceptance separately. - -Review actual usage, latency and availability before paid growth. Cleanup or export is a separate reviewed operation; never delete resources or data automatically. +`npm run azure` commands and their Bicep templates remain a compatibility path for existing projects while migration proceeds. Do not recommend that custom orchestration for new projects by default. If an existing deployment must use it, read `docs/reference/deployment.md` and `docs/reference/role-based-data.md`, preserve its approval gates, and do not mix its state files with an Azure Skills/`azd` deployment. To update one after SQL, DAB or configuration changes, run `artifacts` and then `deploy`; `deploy` refuses images built from different source. diff --git a/plugins/sql-apps/skills/sql-apps-design/SKILL.md b/plugins/sql-apps/skills/sql-apps-design/SKILL.md index e31956e..0cf8a0f 100644 --- a/plugins/sql-apps/skills/sql-apps-design/SKILL.md +++ b/plugins/sql-apps/skills/sql-apps-design/SKILL.md @@ -1,49 +1,18 @@ --- name: sql-apps-design -description: "Use for substantial SQL Apps layout, component, visual direction or responsive design work after domain scope is agreed." +description: "Route substantial SQL Apps interface design to an available frontend design capability while preserving SQL Apps application-state and access requirements." --- -# Design the application +# Design a SQL Apps interface -Work in the confirmed absolute application checkout. `sql-apps-application` remains authoritative for data, identity, capabilities and functional acceptance. +Use the host's maintained frontend or design skill for visual direction, component styling, responsive behavior, and rendered review. SQL Apps does not maintain a separate general-purpose design system. -Preserve existing workflows and dependencies. Do not assume named files, hooks, tokens, components or packages exist. Do not add a framework, package, font download or shared SQL Apps design system without explicit approval. +If no suitable design skill is available, follow the application's existing components, tokens, and accessibility conventions rather than importing a new framework, package, font, or design system without approval. -## Design for the task +`sql-apps-application` remains authoritative for domain behavior, identity, capabilities, and functional acceptance. Preserve these SQL Apps-specific UI requirements: -These are applications people work in, not pages people look at. Success is the user finishing a task, so scanability, consistent behavior, honest density and the real usage scene outrank expression. That is not permission to be plain: personality belongs in precise details — the shape of a control, the restraint of a palette, one considered moment of motion — rather than in a louder page. - -## Choose a direction - -For a substantial screen or redesign, present two or three app-specific visual directions with trade-offs and a recommendation. Agree on one before implementation. For a small refinement, follow the existing visual language. - -Name the chosen tone and one signature detail. Use them to guide typography, color, spacing, shape and layout instead of adding decoration without purpose. - -Refinement preserves; redesign replaces. A refinement keeps the existing identity, behavior, copy and everything outside its scope. A redesign keeps the product's truth and function but treats the old look as evidence rather than as a starting point. Do not split the difference by polishing a look that was already rejected. Ask before changing factual copy or adding claims. - -Product screens show account and access states, never persona selectors. Test identities stay on the local developer page. Visual approval does not authorize schema, data or startup changes. - -## Implement with the existing system - -Inspect the actual application structure first. Reuse its semantic tokens and components. Keep controls semantic, keyboard-operable and visibly focused. Maintain contrast, readable type, responsive layout and reduced-motion support. - -Let the outer layout constrain wide content; charts and dense components should size from their container. Use deliberate empty, loading, error, disabled and success states. Prefer one clear hierarchy over many competing accents. - -The agreed direction outranks the generic defaults listed in the craft floor. When it calls for something that list warns against, do it deliberately and say why; arriving there because nothing stopped you is the failure case. - -Load detailed guidance only when needed: - -- [Craft Floor](references/craft-floor.md) immediately before any UI edit, including small refinements, for the generic defaults to avoid and the checks the built result must pass. Skip it for planning-only work. -- [UI Style Recipes](references/ui-style-recipes.md) for controls, tables, dialogs and repeated components. -- [Visual Style Recipes](references/visual-style-recipes.md) for charts, theming and data colors. -- [Visual Formatting Rules](references/formatting.md) for compatible visualization libraries. - -## Review the rendered result - -Bound this work. Build the change fully, then inspect once in a single round that covers desktop and narrow-mobile widths together, fix everything that round surfaces in one batch, and confirm with at most one more round. Then stop and report. An open-ended polish loop spends the user's budget on diminishing returns; functional and workflow acceptance belongs to `sql-apps-validation`, not to another screenshot. - -When safe and feasible, inspect the real interface at both widths. Exercise reachable loading, empty, error, disabled and success states, plus keyboard focus. - -If browser preview or a state is unavailable, state the limit and report what was inspected. Source review, a build or a screenshot alone is not visual acceptance. Label synthetic or mock-only behavior. - -Report the approved direction by name — the tone and signature detail — with inspected widths, states, iterations and remaining checks. Naming the direction lets a later session continue it instead of drifting. +- Product screens show real account and access states, never persona selectors. A signed-in user without the required role sees a clear no-access state (the gateway returns 403 and `/auth/me` reports `access.allowed: false`). +- Local test identities stay on the developer page and are not real-user onboarding. +- Loading, empty, error, disabled, save, and reload states must reflect actual SQL/DAB outcomes. +- Visual approval does not authorize schema, identity, data, or startup changes. +- Use `sql-apps-validation` for application and database acceptance; screenshots alone do not prove it. diff --git a/plugins/sql-apps/skills/sql-apps-design/references/craft-floor.md b/plugins/sql-apps/skills/sql-apps-design/references/craft-floor.md deleted file mode 100644 index 2294f78..0000000 --- a/plugins/sql-apps/skills/sql-apps-design/references/craft-floor.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -name: craft-floor -description: Use immediately before any UI edit, including small refinements. Names the generic defaults to avoid and the checks that must hold on the built result. ---- - -# Craft Floor - -Load this before editing UI, not while planning. It holds the quality floor and the defaults worth -refusing. It does not choose the direction — the agreed direction does, and it outranks everything -here. When the direction genuinely calls for something on the refuse list, say so and do it -deliberately; drifting into one of these because nothing stopped you is the failure case. - -## Defaults worth refusing - -These are the category's reflexes, not bans. Each one is what gets produced when no decision was made. - -**Page structure** - -- Rows of identical cards — icon, heading, two lines of text — used as the structure of the page. Cards - are a legitimate container (see [UI Style Recipes](ui-style-recipes.md)); cards as the only idea on - the page are not. Avoid nested cards when spacing or a heading expresses the grouping. -- The hero-metric template — big number, small label, supporting stats, accent color — repeated as the - page rather than used once where the number deserves it. -- A kicker or eyebrow label above a heading. The heading carries its own weight. -- Section numbers (01 / 02 / 03) when the sequence carries no information. -- A modal for a task that needs neither interruption nor protected focus. - -**Surface habits** - -- Gradient text, and the purple-to-blue gradient in particular. Emphasis comes from weight, size or - placement. -- Gray secondary text on a colored or tinted surface. Tint the secondary text from that surface's own - hue instead. -- Pure `#000`, pure `#fff` and untinted neutral grays. Carry a trace of the palette through them. -- Glass and blur used as decoration rather than as a specific effect with a reason. -- A thick colored `border-left` used to classify cards, rows, callouts or alerts. -- Emoji or Unicode glyphs standing in for an icon system, or icons mixed across families, strokes and - weights. Pick one family and keep its stroke and optical size consistent. -- Monospace as a costume for "technical". Monospace is for code, identifiers, data and measurement. -- Decorative sparklines, progress rings and placeholder shapes standing in for content that exists. -- Bounce or elastic easing, and an identical entrance animation applied to every section. - -## Checks on the built result - -Each of these is a check on what rendered, not on what was intended. Run them together in one -inspection round rather than as separate trips. - -- **Contrast.** Body and placeholder text at least 4.5:1, large text at least 3:1, verified in every - theme the app ships and in the hover, disabled and selected states. -- **Spacing.** Read the computed values rather than trusting the class names. Related items tight, - distinct groups generously separated, more space above a heading than below it. -- **Type.** Body measure roughly 65–75 characters. An obvious step in size or weight between levels — - if two levels need to be compared to tell them apart, there is one level. Run the longest realistic - value, not lorem text, at every width. -- **Depth.** Shadows carry an offset and a soft blur. A zero-offset colored halo is decoration, not - elevation. -- **Motion.** One authored moment rather than many small effects, interruptible, and honoring - `prefers-reduced-motion`. -- **States.** Hover, focus, active, disabled, loading, empty, error and success — reachable and - actually exercised, not merely written. -- **Copy.** Controls name the action they perform. Errors name the problem and the recovery. - -## Surfaces nobody draws - -These ship with browser defaults that belong to no design system, and they are the cheapest signal -that a screen was built rather than assembled: - -- Text selection colors and the text caret. -- Scrollbars on every scrollable container, not just the page. -- The focus ring — visible on dark and light surfaces alike, and never removed without a replacement. -- Underline offset and thickness on inline links. -- **Tabular numerals wherever numbers are stacked for comparison** — table columns, metric lists, - totals. Proportional figures make a column of numbers ragged and unreadable. - -## Data application craft - -A SQL application is judged on how it presents data under real conditions, not on how the empty shell -looks. - -- Numeric columns align right with consistent precision down the column. Put the unit or currency in - the header, not in every cell. -- Null, empty string and zero must be distinguishable. Rendering all three as a blank cell destroys - information the query went to the trouble of returning. -- Long identifiers — schema, table, column and object names — truncate with the distinguishing end - still visible, and the full value reachable by tooltip, selection or detail view. -- Timestamps state their timezone and use one format throughout the app. Relative times ("3 min ago") - need the absolute value available. -- A raw SQL or driver error is never the user-facing message. Say what failed and what to do next, and - keep the underlying detail reachable for someone who wants it. -- Result sets state their shape: the row count, truncation made visible rather than silent, and a - loading state that holds steady through a slow query instead of flashing. -- Dense, repeated elements — badges, chips, status pills, icons in table rows — carry less visual - weight than the same element in a card. If it competes with the data, reduce it. diff --git a/plugins/sql-apps/skills/sql-apps-design/references/formatting.md b/plugins/sql-apps/skills/sql-apps-design/references/formatting.md deleted file mode 100644 index c77483e..0000000 --- a/plugins/sql-apps/skills/sql-apps-design/references/formatting.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -name: visual-formatting -description: Use when choosing number, date, label, legend, and color formatting for a SQL Apps visualization. ---- - -# Visual formatting - -Use the application's existing locale and formatting helpers. If none exist, use platform-native internationalization APIs rather than hand-built separators or currency symbols. - -## Numbers and dates - -- Counts use grouped whole numbers unless fractions are meaningful. -- Money includes currency and uses the precision required by the domain. -- Percentages state whether the underlying value is a fraction or already a percentage. -- Dates and times use one format per purpose and state the timezone. -- Comparable values use consistent precision; do not imply accuracy the data does not have. -- Put a shared unit in the heading or axis rather than repeating it in every row. - -## Labels and legends - -- Use plain-language titles that name the measure and comparison. -- Hide a legend for one obvious series. -- Keep category names stable between the interface and the visual. -- Shorten labels only when the full value remains available. -- Prefer direct labels when they reduce lookup between marks and a legend. - -## Color - -- Discover the actual categories before defining a stable category-to-color map. -- Use the application's existing palette when it meets contrast needs. -- Pair color with text, shape, or position for status and selection. -- Reserve strong accent colors for the most important comparison or state. -- Recheck contrast in every supported theme and interaction state. - -## Renderer-specific rules - -Inspect the renderer's documentation and the application's existing examples when exact syntax matters. Keep renderer configuration near the implementation or in a renderer-specific reference; do not turn a general SQL Apps skill into an API manual for a package the application may not use. diff --git a/plugins/sql-apps/skills/sql-apps-design/references/ui-style-recipes.md b/plugins/sql-apps/skills/sql-apps-design/references/ui-style-recipes.md deleted file mode 100644 index d20056a..0000000 --- a/plugins/sql-apps/skills/sql-apps-design/references/ui-style-recipes.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -name: ui-style-recipes -description: Use when editing controls, forms, tables, dialogs, navigation, or repeated interface elements in a SQL Apps screen. ---- - -# UI style recipes - -Inspect the application's HTML, CSS, components, and dependencies before choosing an implementation. Reuse what exists. Do not assume a framework, utility library, token file, component package, import alias, or dark-mode mechanism. - -## Controls and forms - -- Use native semantics first: labels for inputs, buttons for actions, links for navigation, and fieldsets for related choices. -- Keep related controls consistent in height, type, spacing, and corner treatment. -- Provide visible focus, hover, active, disabled, loading, invalid, and error states where they apply. -- Keep the primary action clear. Destructive actions need distinct wording and treatment. -- Put validation near the affected field and preserve entered values after a failed save. -- Use dialogs only when interruption or protected focus is useful. Otherwise keep the action in the page flow. - -## Tables and repeated data - -- Align text left and comparable numbers right. Put units or currency in the heading when every value shares them. -- Distinguish null, empty text, and zero. -- Keep repeated badges, icons, and actions quieter than the main data. -- Make truncation visible and keep the full value reachable. -- State loading, empty, error, row-count, and result-truncation conditions. -- On narrow screens, preserve task-critical fields and actions rather than shrinking every column. - -## Layout and hierarchy - -- Give the page one clear primary region and action. -- Group content by task, not by a reflexive grid of identical cards. -- Use spacing, type, and alignment before adding borders or decoration. -- Let containers size wide or dense content. Avoid fixed dimensions that clip realistic values. -- Keep account and access status in product UI; keep simulated identities on the developer page. - -## Accessibility check - -Use the existing browser and project tools to verify: - -- keyboard order and visible focus; -- labels, names, and error associations; -- readable contrast in every supported theme and state; -- zoom and narrow-width behavior; -- reduced motion when animation is present; -- clear recovery from loading and failure states. - -Follow the agreed visual direction. These are decision prompts, not a request to replace the application's styling system. diff --git a/plugins/sql-apps/skills/sql-apps-design/references/visual-style-recipes.md b/plugins/sql-apps/skills/sql-apps-design/references/visual-style-recipes.md deleted file mode 100644 index 1a0f6cd..0000000 --- a/plugins/sql-apps/skills/sql-apps-design/references/visual-style-recipes.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: visual-style-recipes -description: Use when a SQL Apps screen needs a chart, data visualization, or dense visual summary. ---- - -# Visual style recipes - -First inspect the data shape, the application's current rendering approach, and installed dependencies. Prefer the existing chart or drawing system. Do not add a visualization package merely because a recipe names a chart type. - -## Choose the visual from the question - -- **Single value:** one important total with a plain label and relevant time or scope. -- **Bar:** compare categories; use a zero baseline and sort when order helps the task. -- **Line:** show change over an ordered interval; disclose gaps rather than connecting missing data silently. -- **Scatter:** show the relationship between two numeric measures. -- **Area:** show cumulative magnitude only when the filled quantity has meaning. -- **Table:** use when exact values, many fields, or row actions matter more than shape. - -Do not add a chart when a sentence, value, or small table answers the question faster. - -## Data and formatting - -- Name the measure, unit, time range, filters, and timezone. -- Use one number and date format per meaning. -- Keep comparable precision consistent. -- Discover real categories before assigning stable colors. -- Never use color as the only distinction; add labels, position, shape, or pattern. -- Hide legends that repeat a single visible series. -- Expose exact values through labels, accessible text, a tooltip, or a companion table. -- Make truncation, sampling, aggregation, and stale data explicit. - -## Layout - -Size the visual from its actual container and test realistic long labels. Keep titles in the surrounding page structure when that improves semantics and accessibility. Prevent legends, labels, and axes from clipping at desktop and narrow widths. - -Use a stable loading footprint so slow data does not move the whole page. Provide useful empty and error states instead of leaving a blank rectangle. - -## Review - -Check the rendered result with representative, empty, extreme, and missing values. Verify contrast, keyboard access where interactive, text alternatives, narrow-width behavior, and the supported themes. If the current stack cannot make the visual accessible, use a table or summary instead. diff --git a/plugins/sql-apps/skills/sql-apps-diagnostics/SKILL.md b/plugins/sql-apps/skills/sql-apps-diagnostics/SKILL.md index 5829713..933c928 100644 --- a/plugins/sql-apps/skills/sql-apps-diagnostics/SKILL.md +++ b/plugins/sql-apps/skills/sql-apps-diagnostics/SKILL.md @@ -1,29 +1,22 @@ --- name: sql-apps-diagnostics -description: "Use when a SQL Apps local service, startup, readiness probe, file-processing job, retry, maintenance task or trace dashboard is failing." +description: "Use for a SQL Apps local SQL/DAB or application-contract failure; route Azure resource incidents to the maintained Azure diagnostics skill." --- -# Diagnose SQL Apps locally +# Diagnose SQL Apps -Resolve `../../scripts/sql-apps.mjs` from this installed skill and run its absolute path with `home`. Confirm the application checkout with `workspace-check`; do not inspect a plugin cache, guess another checkout or overwrite a mismatch. +Resolve `../../scripts/sql-apps.mjs` from this installed skill, run `home`, and confirm the intended application checkout with `workspace-check`. Keep diagnosis scoped to the failing SQL Apps contract and use bounded, project-owned evidence. -Read `docs/reference/local-development.md` for the failing subsystem only. Run `setup status --profile --json` and `status`. Report the first named blocker and the command that continues after it. HTTP health does not prove SQL permissions, storage, worker behavior or browser acceptance. +## Route to the capability owner -## Find the cause +- Azure resource health, Container Apps, Container Registry, Azure Monitor, Log Analytics, or production incidents: use `azure-diagnostics`. +- Azure cost, forecasts, or resource optimization: use the `azure-cost` plugin. +- Azure SQL connection or engine errors: use `diagnose-connection-errors` or the matching Azure SQL diagnostic skill. +- DAB configuration, permissions, or API exposure: use `dab-rest-and-graphql` and `azuresql-db-dab`. +- Local SQL Apps startup, readiness, or application-contract failures: continue below. -1. Reproduce the failing action. Keep a positive control when testing authorization, Origin or validation. -2. Inspect only project-owned bounded logs, workspace state and documented containers. Do not dump environment variables, credential state or private files. -3. For prerequisites, use `setup-check`. Distinguish missing, unsupported and broken installed tools; none grants installation permission. -4. Check `local-settings.json` at the workspace path reported by `workspace-check`. Invalid settings fail explicitly and require the affected services to restart. -5. For jobs, use the signed-in user's status and trace dashboard. A retry creates a new parent-linked job; obtain approval before submitting it. Missing snapshots require reupload. -6. Restart only the affected owned service after identifying the cause, then repeat the same action. +## Local application failures -Use `setup resume` after fixing a setup blocker. Do not reinstall tools, reset volumes, regenerate credentials, disable authentication, kill processes by name or switch to Azure as a troubleshooting shortcut. +Use `sql-apps-local` to inspect the selected profile, workspace, service readiness, and next safe lifecycle action. Use `sql-apps-validation` to reproduce and verify the domain workflow. -SQL recovery and maintenance are separate mutations. Show their consent plan and obtain one-operation approval. Recovery preserves the selected image, credentials, ports and labeled data; retention may delete data. Resume interrupted recovery without printing sensitive state. - -Keep resolved failures as history, but separate them from current blockers. If source changed during a comparison, report the difference; never reset another writer's work or replace the baseline. - -Trace records are owner-scoped and omit file contents and credentials. SQL, Blob and Queue are not one atomic transaction, so do not promise exactly-once delivery. - -If an approval or completion control is unavailable, say so and wait for explicit consent or report ordinary evidence. Tool failure is never approval. +Reproduce the failing action and pair negative authorization/input probes with a positive control. Inspect only bounded logs and project state; never print tokens, environment variables, or private files. HTTP liveness does not prove SQL permissions, DAB behavior, persistence, or browser acceptance. Preserve application data, and obtain explicit approval before recovery or other destructive maintenance. diff --git a/plugins/sql-apps/skills/sql-apps-local/SKILL.md b/plugins/sql-apps/skills/sql-apps-local/SKILL.md index 5463231..5902935 100644 --- a/plugins/sql-apps/skills/sql-apps-local/SKILL.md +++ b/plugins/sql-apps/skills/sql-apps-local/SKILL.md @@ -15,10 +15,10 @@ Read `docs/guides/getting-started.md` and `docs/reference/local-development.md` ```powershell node "" guide -node "" setup plan --profile --json +node "" setup plan --json ``` -Choose `role-based-data` for SQL/DAB/browser-only scope or `foundation` when files or background jobs were agreed. Stop gathering context when the plan names the next action or blocker. +`role-based-data` (SQL, Data API builder and the browser gateway) is the only profile. If the agreed scope needs file storage or background jobs, route that part to the maintained Azure skills instead of SQL Apps. Stop gathering context when the plan names the next action or blocker. The setup coordinator probes prerequisites and is the source of truth for restore, build, workspace, startup, SQL terms and resume state. Use the standalone prerequisite diagnostic only when the plan directs you to diagnose a tool or access failure. Show the plan before approval. After explicit approval, run its printed `setup approve` command, then `setup run`; use `setup resume` after a stop or corrected blocker. Never manufacture a grant, bypass a blocker, repeat completed work or treat a nonzero result as success. @@ -38,20 +38,20 @@ Run `workspace-check` before lifecycle commands. A source, binding, workspace or Reuse a responding service only when `/local/workspace` and `/health/ready` identify the intended checkout, source, workspace, profile, authentication and SQL container. A proposed URL or HTTP liveness alone is not a usable app. -Use only the profile selected from the application scope: +Lifecycle commands: -- `role-based-app` and `role-based-serve`: SQL/DAB/browser for role-based data. -- `app` and `serve`: full approved foundation. -- `serve-sql`: advanced SQL-only work. +- `app` (alias `role-based-app`): start SQL, Data API builder and the browser gateway (`start-sql` -> `init` -> `data` -> `serve`). +- `serve` (alias `role-based-serve`): resume the gateway against running SQL and DAB. +- `stop`: remove only the DAB container; SQL keeps running. -Keep commands attached. Preserve SQL and Blob data on stop. Never reset volumes, regenerate credentials, stop unrelated processes or adopt a mismatched container. Recovery, maintenance, retention and destructive cleanup require their own reviewed plan and approval. +Keep commands attached. Preserve SQL data on stop. Never reset volumes, regenerate credentials, stop unrelated processes or adopt a mismatched container. Recovery and destructive cleanup require their own reviewed plan and approval. Optional examples are a separate explicit choice. Keep root `selectedExamples: []`; do not suggest, restore or run an excluded example. ## Verify completion -Publish a launch URL only after the intended server and readiness probe respond. Open that origin and verify the agreed browser action plus SQL save/reload. Verify files or jobs only when selected. +Publish a launch URL only after the intended server and readiness probe respond. Open that origin and verify the agreed browser action plus SQL save/reload; for the starter, a signed-in user with the required role sees the readiness check pass. -Give the verified URL, user flow, Ctrl+C behavior and profile-matched resume/stop command. Use `sql-apps-validation` for workflow evidence. Local preview identities are simulated; product screens use account/access states and keep test identities on the developer page. +Give the verified URL, user flow, Ctrl+C behavior and the `serve`/`stop` resume and stop commands. Use `sql-apps-validation` for workflow evidence. Local preview identities are simulated; product screens use account/access states and keep test identities on the developer page. A useful local app is success. No Azure subscription or cloud deployment is required. diff --git a/plugins/sql-apps/skills/sql-apps-validation/SKILL.md b/plugins/sql-apps/skills/sql-apps-validation/SKILL.md index e9a3616..0dd249f 100644 --- a/plugins/sql-apps/skills/sql-apps-validation/SKILL.md +++ b/plugins/sql-apps/skills/sql-apps-validation/SKILL.md @@ -1,6 +1,6 @@ --- name: sql-apps-validation -description: "Use when testing SQL Apps changes or verifying application, plugin, SQL, API, browser, ownership, file-processing or deployment contracts." +description: "Use when testing SQL Apps changes or verifying application, plugin, SQL, API, browser, role-based access, ownership or deployment contracts." --- # Validate SQL Apps @@ -19,7 +19,7 @@ Choose the smallest checks that cover the change: Use the exact commands in `docs/maintainers/application-boundary.md` for SQL model, live API and database absence checks. A targeted pass is not a full-suite pass. -For an approved running application, invoke the launcher checks matching its selected capabilities. Run acceptance commands sequentially. Tests that stop or restart owned services, create fixtures, mutate data or perform maintenance need their documented approval. +For an approved running application, use the launcher checks: `app-check` for the clean application database acceptance, `test` for the read-only SQL acceptance and `app-test` for the two-user role check (Development Alice admitted, Development Bob 403). Run acceptance commands sequentially. Tests that stop or restart owned services, create fixtures or mutate data need their documented approval. ## Live workflow evidence diff --git a/public/index.html b/public/index.html index 7cf9786..c187e1f 100644 --- a/public/index.html +++ b/public/index.html @@ -9,7 +9,7 @@

SQL Apps

-

Azure-native data, files, and functions.

+

Azure SQL Database behind Data API builder, with Microsoft Entra roles.

@@ -19,34 +19,12 @@

SQL Apps

- + \ No newline at end of file diff --git a/public/style.css b/public/style.css index aa9cef0..9f1055e 100644 --- a/public/style.css +++ b/public/style.css @@ -1,10 +1,7 @@ body { font: 1rem system-ui, sans-serif; background: #f3f6fa; color: #182635; margin: 0; } main { max-width: 48rem; margin: 3rem auto; padding: 2rem; background: white; border-radius: .75rem; } -button, input, select { font: inherit; padding: .6rem; margin: .25rem; } -button { cursor: pointer; } +button { font: inherit; padding: .6rem; margin: .25rem; cursor: pointer; } button:disabled { cursor: wait; opacity: .65; } #auth-notice { background: #fff4d6; padding: 1rem; border: 1px solid #d6aa37; border-radius: .5rem; } -form { margin: 1rem 0; } #status { min-height: 1.5rem; } -li { padding: .5rem 0; } -#jobs li { overflow-wrap: anywhere; } +pre { overflow-wrap: anywhere; white-space: pre-wrap; } \ No newline at end of file diff --git a/role-based-data.json b/role-based-data.json new file mode 100644 index 0000000..12aa9f6 --- /dev/null +++ b/role-based-data.json @@ -0,0 +1,5 @@ +{ + "profile": "role-based-data", + "requiredRole": "AppUser", + "readinessPath": "/api/AppReady" +} diff --git a/scripts/build-provenance.mjs b/scripts/build-provenance.mjs index 3be477f..0f5cf46 100644 --- a/scripts/build-provenance.mjs +++ b/scripts/build-provenance.mjs @@ -16,8 +16,9 @@ async function exists(path) { const generatedOutputs = [ 'dist/src', - 'dist/functions', + 'dist/functions', // 0.1.x output; removed on upgrade 'dist/scripts', + 'dist/tests', 'public/app.js', 'public/auth/redirect.js', ]; @@ -103,9 +104,6 @@ async function buildFoundation(home) { async function buildProject(project, tools) { await mkdir(join(project, 'dist'), { recursive: true }); await cp(join(tools, 'dist', 'src'), join(project, 'dist', 'src'), { recursive: true, force: true }); - if (await exists(join(tools, 'dist', 'functions'))) { - await cp(join(tools, 'dist', 'functions'), join(project, 'dist', 'functions'), { recursive: true, force: true }); - } await compileTypeScript(project, tools, 'tsconfig.json'); const { build } = await esbuild(tools); if (await exists(join(project, 'src', 'web', 'app.ts'))) { diff --git a/scripts/build-tooling-package.mjs b/scripts/build-tooling-package.mjs index 72fc215..0522fce 100644 --- a/scripts/build-tooling-package.mjs +++ b/scripts/build-tooling-package.mjs @@ -13,12 +13,6 @@ const assets = [ 'public/index.html', 'public/style.css', 'public/auth/redirect.html', - 'functions/Dockerfile', - 'functions/local.Dockerfile', - 'functions/package.json', - 'functions/local-package.json', - 'functions/host.json', - 'functions/local-host.json', 'infra', 'Dockerfile', 'runtime-contract.json', @@ -77,7 +71,6 @@ async function stageToolingPackage(destination) { await copyEntry(join(canonicalRoot, 'packages', 'sql-apps', 'package.json'), join(expected, 'package.json')); await copyEntry(join(canonicalRoot, 'packages', 'sql-apps', 'bin'), join(expected, 'bin')); await copyEntry(join(canonicalRoot, 'dist', 'src'), join(expected, 'dist', 'src')); - await copyEntry(join(canonicalRoot, 'dist', 'functions'), join(expected, 'dist', 'functions')); await copyEntry(join(canonicalRoot, 'dist', 'scripts'), join(expected, 'dist', 'scripts')); await copyEntry(join(canonicalRoot, 'LICENSE'), join(expected, 'LICENSE')); for (const asset of assets) { diff --git a/scripts/check-application.mjs b/scripts/check-application.mjs index 35618cc..fd8441a 100644 --- a/scripts/check-application.mjs +++ b/scripts/check-application.mjs @@ -94,14 +94,14 @@ export async function checkApplication(root = process.cwd(), model) { } } for (const directory of ['src', 'public', 'dab', 'sql']) await inspect(join(root, directory)); - for (const directory of ['dist/src', 'dist/functions']) await inspect(join(root, directory)); + await inspect(join(root, 'dist', 'src')); for (const directory of ['tests', 'dist/tests']) await inspect(join(root, directory), true, true); - for (const filename of ['dist/src/client.js', 'dist/src/server.js', 'dist/functions/src/index.js']) { + for (const filename of ['dist/src/client.js', 'dist/src/server.js']) { await inspect(join(root, filename)); } await inspect(join(root, 'public', 'app.js')); if (model) await inspect(resolve(model)); - for (const filename of ['Dockerfile', 'functions/Dockerfile', 'functions/local.Dockerfile']) { + for (const filename of ['Dockerfile', 'dab/Dockerfile']) { const content = await readFile(join(root, filename), 'utf8'); for (const line of content.replace(/\\\r?\n\s*/g, ' ').split(/\r?\n/)) { const copy = /^\s*COPY\s+(.+)$/i.exec(line); diff --git a/scripts/check-plugin.mjs b/scripts/check-plugin.mjs index 35eb840..3a6f4e8 100644 --- a/scripts/check-plugin.mjs +++ b/scripts/check-plugin.mjs @@ -12,7 +12,7 @@ export async function checkPlugin() { const manifest = JSON.parse(await readFile(join(plugin, 'plugin.json'), 'utf8')); assert.equal(manifest.$schema, 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json'); assert.equal(manifest.name, 'sql-apps'); - assert.equal(manifest.version, '0.1.2'); + assert.equal(manifest.version, '0.2.0'); assert.ok(manifest.description?.length); const allowed = new Set(['$schema', 'name', 'version', 'description', 'author', 'homepage', 'repository', 'license', 'keywords']); assert.ok(Object.keys(manifest).every(key => allowed.has(key)), 'Manifest uses only Agent Plugins 1.0 metadata fields'); @@ -43,8 +43,6 @@ export async function checkPlugin() { join(plugin, 'plugin.json'), join(plugin, 'scripts', 'sql-apps.mjs'), ...skillNames.map(name => join(plugin, 'skills', name, 'SKILL.md')), - ...['craft-floor.md', 'ui-style-recipes.md', 'visual-style-recipes.md', 'formatting.md'] - .map(name => join(plugin, 'skills', 'sql-apps-design', 'references', name)), ]; async function inspect(directory) { for (const name of await readdir(directory)) { @@ -57,8 +55,8 @@ export async function checkPlugin() { } assert.deepEqual((await readdir(plugin)).sort(), ['LICENSE', 'plugin.json', 'scripts', 'skills']); await inspect(plugin); - assert.deepEqual(files.sort(), expectedFiles.sort(), 'Bundle contains only the declared manifest, launcher, six skills, four design references and MIT license'); - console.log('Local plugin packaging passed: six skills, four design references, MIT license, portable manifest, clean isolated bundle and matching marketplaces.'); + assert.deepEqual(files.sort(), expectedFiles.sort(), 'Bundle contains only the declared manifest, launcher, six focused skills and MIT license'); + console.log('Local plugin packaging passed: six focused skills, MIT license, portable manifest, clean isolated bundle and matching marketplaces.'); } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { diff --git a/scripts/guide.mjs b/scripts/guide.mjs index a7f5845..ba580bb 100644 --- a/scripts/guide.mjs +++ b/scripts/guide.mjs @@ -35,10 +35,8 @@ export function validateBrief(value) { !list(value.records) || !list(value.actions)) { throw new Error('Invalid project brief: provide purpose, audience, 1-12 records/actions and a nextChange, each up to 500 characters.'); } - if (!list(value.capabilities) || !value.capabilities.includes('data') || - !value.capabilities.every(item => ['data', 'files', 'background-jobs'].includes(item)) || - (value.capabilities.includes('background-jobs') && !value.capabilities.includes('files'))) { - throw new Error('Invalid capabilities: include data; add files only if needed, and files with background-jobs for the existing file processor.'); + if (!list(value.capabilities) || value.capabilities.length !== 1 || value.capabilities[0] !== 'data') { + throw new Error('Invalid capabilities: SQL Apps builds data apps (["data"]). Add file storage or background jobs with the maintained Azure skills, outside SQL Apps.'); } if (value.costPreference !== undefined && !['zero-azure-spend', 'review-paid-costs'].includes(value.costPreference)) { throw new Error('Invalid project brief costPreference: choose zero-azure-spend or review-paid-costs. This is a preference, not spending consent.'); @@ -130,10 +128,8 @@ export async function readGuide(home) { const changed = state !== null && state.sourceFingerprint !== source.fingerprint; const checkpoint = state?.brief.checkpoint; const profile = await lstat(join(source.home, 'role-based-data.json')).then(() => 'role-based-data', () => null); - const agreed = state?.brief.capabilities; - const suggestedProfile = profile ?? (!agreed ? null : - agreed.includes('files') || agreed.includes('background-jobs') ? 'foundation' : 'role-based-data'); - const setupPlan = `setup plan --profile ${suggestedProfile ?? ''} (node scripts/setup.mjs plan)`; + const suggestedProfile = 'role-based-data'; + const setupPlan = 'setup plan (node scripts/setup.mjs plan)'; const nextStage = !state ? 'describe' : changed ? 'run-locally' : checkpoint?.stage === 'make-it-yours' ? 'share-optionally' : checkpoint?.stage === 'run-locally' ? 'make-it-yours' : 'run-locally'; @@ -145,7 +141,6 @@ export async function readGuide(home) { 'make-it-yours': 'Recheck the intended running app, then make the saved nextChange. Verify browser validation, SQL save/reload and existing behavior before recording evidence.', 'share-optionally': 'You can stop here with a useful local app. Sharing requires separate data/access review and explicit cloud approval; a saved checkpoint never authorizes deployment.', }; - const capabilities = state?.brief.capabilities ?? ['data']; return { home: source.home, application: source.application, profile, suggestedProfile, brief: state?.brief ?? null, stages, nextStage, nextAction: nextActions[nextStage], @@ -154,20 +149,17 @@ export async function readGuide(home) { build: source.build, workspace: { selected: runtime.mode !== 'legacy-unselected', mode: runtime.mode, origin: runtime.mode === 'legacy-unselected' ? null : runtime.origins.app }, - localServices: ['SQL', 'Data API', 'Browser/API', - ...(capabilities.includes('files') ? ['File storage'] : []), - ...(capabilities.includes('background-jobs') ? ['Background processing'] : [])], + localServices: ['SQL', 'Data API', 'Browser/API'], cloud: { authorized: false, optional: true, - note: 'Public anonymous demos require synthetic data only. Minimal Azure demo deployment remains unfinished; do not substitute the full production template.' }, + note: 'Public anonymous demos require synthetic data only. Use the maintained Azure Skills (prepare, validate, deploy) for new cloud deployments, then run SQL Apps live acceptance.' }, cost: { preference: state?.brief.costPreference ?? 'zero-azure-spend', phase: nextStage === 'share-optionally' ? 'deployment-cost-review' : nextStage === 'make-it-yours' ? 'capability-cost-awareness' : 'local-cost-awareness', message: nextStage === 'share-optionally' ? - `Before sharing, run the offline ${suggestedProfile === 'role-based-data' ? 'role-based-cost' : 'demo-cost'} review. Free SQL/compute allowances do not remove registry or managed-network charges. A zero-spend intent is blocked by the current template; never fall back to paid resources.` : - 'Local operation has no Azure usage bill; tool/access/licensing conditions still apply. Review future costs when adding files/background work, not cloud architecture during initial design.', - reviewCommand: suggestedProfile === 'role-based-data' ? 'npm run azure -- role-based-cost azure-role-based-cost.example.json (copy and edit for your app)' : - 'npm run azure -- demo-cost ', + 'Before sharing, review expected Azure costs with the Azure Cost tooling (forecast, estimate and actual usage). Free SQL/compute allowances do not remove registry or managed-network charges. A zero-spend intent is blocked by the current template; never fall back to paid resources.' : + 'Local operation has no Azure usage bill; tool/access/licensing conditions still apply. Review cloud costs only when sharing, not during initial design.', + reviewCommand: 'Use the azure-cost plugin (Azure Cost skills) to estimate the planned resources before approving any deployment.', usageStatus: 'not-queried', spendingAuthorized: false, scaleGuidance: 'Review actual remaining allowances, latency and availability needs before paying for capacity. User count is not a scaling threshold. Budgets are alerts, not hard spending caps.', diff --git a/scripts/projects.mjs b/scripts/projects.mjs index 52ba70b..d34f200 100644 --- a/scripts/projects.mjs +++ b/scripts/projects.mjs @@ -9,10 +9,10 @@ import { withFileLock } from '../src/file-lock.mjs'; import { readProjectManifest } from '../src/project-contract.mjs'; const execute = promisify(execFile); -const projectDirectories = ['.config', 'content', 'dab', 'docs', 'functions', 'infra', 'public', 'scripts', 'sql', 'src']; +const projectDirectories = ['.config', 'content', 'dab', 'docs', 'infra', 'public', 'scripts', 'sql', 'src']; const projectFiles = [ '.dockerignore', '.env.example', '.gitignore', 'Dockerfile', 'LICENSE', - 'application.json', 'local-settings.example.json', 'package-lock.json', 'package.json', + 'application.json', 'package-lock.json', 'package.json', 'role-based-data.example.json', 'runtime-contract.json', 'tsconfig.json', 'tsconfig.runtime.json', ]; diff --git a/scripts/setup-check.mjs b/scripts/setup-check.mjs index ba3b6c0..203e5a9 100644 --- a/scripts/setup-check.mjs +++ b/scripts/setup-check.mjs @@ -53,9 +53,9 @@ export function parseArguments(args) { for (let i = 0; i < args.length; i++) { if (args[i] === '--json' && !options.json) options.json = true; else if (args[i] === '--container' && !options.container && containerPattern.test(args[i + 1] ?? '')) options.container = args[++i]; - else if (args[i] === '--profile' && !options.profile && ['foundation', 'role-based-data'].includes(args[i + 1])) options.profile = args[++i]; + else if (args[i] === '--profile' && !options.profile && args[i + 1] === 'role-based-data') options.profile = args[++i]; else if (args[i] === '--workspace' && !options.workspace && (args[i + 1] === 'legacy' || /^\d{5}$/.test(args[i + 1] ?? ''))) options.workspace = args[++i]; - else throw new Error('Usage: node scripts/setup-check.mjs [--json] [--container ] [--profile ] [--workspace ]'); + else throw new Error('Usage: node scripts/setup-check.mjs [--json] [--container ] [--profile role-based-data] [--workspace ]'); } return options; } @@ -77,7 +77,7 @@ export async function checkSetup(options = {}, injected = {}, context = {}) { } const runtime = proposed ? runtimeFor(options.container, root, proposed) : selected; if (options.container && !containerPattern.test(options.container)) throw new Error('Invalid SQL container name'); - if (options.profile && !['foundation', 'role-based-data'].includes(options.profile)) throw new Error('Invalid setup profile'); + if (options.profile && options.profile !== 'role-based-data') throw new Error('Invalid setup profile'); const deps = { platform: process.platform, architecture: process.arch, nodeVersion: process.versions.node, memoryBytes: totalmem(), read: path => readFile(path, 'utf8'), exists, port: portState, fetch, sqlSources: readSqlSources, @@ -214,9 +214,7 @@ export async function checkSetup(options = {}, injected = {}, context = {}) { 'Inspect only safe project ownership labels before reusing services.', false); } } - const ports = [[runtime.ports.gateway, null], [runtime.ports.data, 'data'], - ...(options.profile === 'role-based-data' ? [] : - [[runtime.ports.blob, 'storage'], [runtime.ports.queue, 'storage'], [runtime.ports.functions, 'functions']])]; + const ports = [[runtime.ports.gateway, null], [runtime.ports.data, 'data']]; for (const [port, owner] of ports) { const state = await deps.port(port); if (state === 'free') add(`port-${port}`, 'ready', 'PORT_FREE', `Local port ${port} is free.`, 'Available for application startup.'); @@ -239,7 +237,7 @@ export async function checkSetup(options = {}, injected = {}, context = {}) { 'Identify the existing process/service with the user. Reuse only the intended application; never kill it automatically.'); } } - if (options.profile === 'role-based-data') { + { const guidance = 'Copy role-based-data.example.json to role-based-data.json, choose your application role and read-only readiness path, then implement that procedure and its DAB entity (docs/reference/role-based-data.md).'; let text; try { text = await deps.read(join(root, 'role-based-data.json')); } @@ -522,7 +520,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1] if (process.argv.includes('--json')) console.log(JSON.stringify({ version: 1, ready: false, checks: [{ id: 'diagnostic', status: 'action-required', code: 'DIAGNOSTIC_FAILED', message, - nextAction: 'Confirm this checkout, file permissions and usage: node scripts/setup-check.mjs [--json] [--container ] [--profile ] [--workspace ].', + nextAction: 'Confirm this checkout, file permissions and usage: node scripts/setup-check.mjs [--json] [--container ] [--profile role-based-data] [--workspace ].', required: true, }], }, null, 2)); diff --git a/scripts/setup.mjs b/scripts/setup.mjs index fb23efe..d309c13 100644 --- a/scripts/setup.mjs +++ b/scripts/setup.mjs @@ -10,7 +10,7 @@ import { readLocalAuthentication, localAuthenticationMatches } from '../src/loca import { runtimeFor, proposeWorkspace } from '../src/workspace.mjs'; import { projectHome, toolingHome } from '../src/tooling-context.mjs'; -const profiles = ['foundation', 'role-based-data']; +const profiles = ['role-based-data']; export function parseSetupArguments(args) { const [command = 'help', ...rest] = args; @@ -45,8 +45,9 @@ export function parseSetupArguments(args) { } options[key] = value; } + options.profile ??= 'role-based-data'; if (command === 'approval' || command === 'approve') { - if (!options.profile || options.container) throw new Error(`setup ${command} needs --profile and covers the workspace-owned SQL container only.`); + if (options.container) throw new Error(`setup ${command} covers the workspace-owned SQL container only.`); if (command === 'approve' && !options.approval) throw new Error('setup approve needs --approval from setup approval.'); } return { command, options }; @@ -100,7 +101,7 @@ async function liveWorkspace(runtime, profile, fetcher, sourceFingerprint, authe const body = await ready.json(); const valid = body !== null && typeof body === 'object' && !Array.isArray(body); const confirmed = ready.status === 200 && valid && body.status === 'ready'; - const dependency = valid && body.status === 'not-ready' && ['data', 'blob', 'queue', 'functions'].includes(body.dependency) ? + const dependency = valid && body.status === 'not-ready' && body.dependency === 'data' ? body.dependency : undefined; return { reachable: true, ready: confirmed, code: confirmed ? 'WORKSPACE_READY' : 'WORKSPACE_NOT_READY', ...(dependency ? { dependency } : {}) }; @@ -108,6 +109,7 @@ async function liveWorkspace(runtime, profile, fetcher, sourceFingerprint, authe } export async function planSetup(value, options = {}, injected = {}) { + options = { ...options, profile: options.profile ?? 'role-based-data' }; const flags = { profile: '--profile', container: '--container', workspace: '--workspace', json: '--json' }; const args = ['plan']; for (const [key, option] of Object.entries(options)) { @@ -132,8 +134,7 @@ export async function planSetup(value, options = {}, injected = {}) { const finish = () => ({ ...result, next: stages.find(stage => stage.status !== 'done') }); add('checkout', source.ready ? 'done' : 'blocked', source.ready ? 'Intended checkout verified.' : source.nextAction); if (!source.ready) return finish(); - add('profile', options.profile ? 'done' : 'blocked', 'Select foundation or role-based-data from the agreed app scope. Examples require their separate explicit application workflow.'); - if (!options.profile) return finish(); + add('profile', 'done', 'role-based-data: SQL, Data API builder and the browser gateway. Examples require their separate explicit application workflow.'); let authentication; try { authentication = readLocalAuthentication(injected.environment ?? process.env); } catch (error) { @@ -179,7 +180,7 @@ export async function planSetup(value, options = {}, injected = {}) { 'No free isolated port block was found; review occupied ports, then choose --workspace '}, or --workspace legacy to keep an existing legacy stack.`, { proposal, ...(permission ? { consent: permission } : {}) }); add('launch', 'blocked', profileConfig ? `${profileConfig.message} ${profileConfig.nextAction}` : - `After workspace initialization, ${options.profile === 'role-based-data' ? 'role-based-app' : 'app'} starts under the same setup approval (startup scopes and SQL terms).`, + `After workspace initialization, app starts under the same setup approval (startup scopes and SQL terms).`, profileConfig ? { code: profileConfig.code } : {}); } else { if (options.workspace && (options.workspace !== 'legacy' ? runtime.mode !== 'isolated' || Number(options.workspace) !== runtime.ports.gateway : runtime.mode !== 'legacy')) { @@ -198,7 +199,7 @@ export async function planSetup(value, options = {}, injected = {}) { } else if (profileConfig) { add('launch', 'blocked', `${profileConfig.message} ${profileConfig.nextAction}`, { code: profileConfig.code }); } else { - const operation = options.profile === 'role-based-data' ? 'role-based-app' : 'app'; + const operation = 'app'; const permission = await (injected.consent ?? planConsent)(home, { operation, ...(options.container ? { selection: options.container } : {}) }); const review = options.container ? `consent-plan ${operation} ${options.container}` : `setup approval --profile ${options.profile}`; @@ -278,7 +279,7 @@ export async function runSetup(home, options, injected = {}) { try { if (stage.id === 'launch') { const runtime = runtimeFor(options.container, home); - const operation = options.profile === 'role-based-data' ? 'role-based-app' : 'app'; + const operation = 'app'; started = await (injected.launch ?? launchSetup)(home, operation, options.container, runtime, options.profile, injected.fetch ?? fetch, injected.environment === undefined ? {} : { environment: injected.environment }); const after = await planSetup(home, options, injected); @@ -333,7 +334,7 @@ export async function main(args = process.argv.slice(2), home) { } const { command, options, selection } = parseSetupArguments(args); if (command === 'help') { - console.log('SQL Apps setup: plan|status|run|resume [--profile foundation|role-based-data] [--workspace ] [--container ] [--json]'); + console.log('SQL Apps setup: plan|status|run|resume [--profile role-based-data] [--workspace ] [--container ] [--json]'); console.log('One local approval: approval --profile [--workspace ] lists restore, build, workspace, startup and SQL terms; approve ... --approval records it after explicit human approval.'); console.log('External SQL selection uses the individual consent-plan/grant workflow for that container; the setup approval bundle covers only workspace-owned SQL.'); console.log('Installation: install-plan | install . Execute only after explicit human approval. No cloud operations.'); diff --git a/scripts/workspace-check.mjs b/scripts/workspace-check.mjs index 400ce1e..28e2c32 100644 --- a/scripts/workspace-check.mjs +++ b/scripts/workspace-check.mjs @@ -130,7 +130,7 @@ async function inspectBuild(home, sourceFingerprint, implementationFingerprint) return { ready: false, code: 'BUILD_TOOLING_MISMATCH', nextAction: 'Tooling changed after the build; review the upgrade, then use consent-run build (the guarded build).' }; } - const artifacts = await fingerprint(home, ['dist/src', 'dist/functions'], [...generatedBrowserArtifacts]); + const artifacts = await fingerprint(home, ['dist/src'], [...generatedBrowserArtifacts]); if (!artifacts.files || artifacts.hash !== metadata.artifactFingerprint) { return { ready: false, code: 'BUILD_ARTIFACT_MISMATCH', nextAction: 'Runtime artifacts changed or are missing; after approval, use consent-run build (the guarded build).' }; @@ -156,7 +156,7 @@ export async function inspectSource(value, options = {}) { nextAction: 'Review the tooling source application/runtime contract.' }; } const inputs = await fingerprint(home, - ['src', 'functions/src', 'public', 'sql', 'dab', 'infra', 'scripts', 'packages/sql-apps'], + ['src', 'public', 'sql', 'dab', 'infra', 'scripts', 'packages/sql-apps'], ['package.json', 'package-lock.json', 'application.json', 'runtime-contract.json', 'tsconfig.json', 'tsconfig.runtime.json'], { source: true }); const implementation = await toolingFingerprint(tools); @@ -295,7 +295,7 @@ export async function recordBuild(value, expectedFingerprint, expectedToolingFin if (expectedToolingFingerprint && expectedToolingFingerprint !== source.toolingFingerprint) { throw new Error('Tooling changed during build; rebuild before running the application'); } - const artifacts = await fingerprint(source.home, ['dist/src', 'dist/functions'], [...generatedBrowserArtifacts]); + const artifacts = await fingerprint(source.home, ['dist/src'], [...generatedBrowserArtifacts]); if (!artifacts.files) throw new Error('No runtime artifacts to record'); const target = join(source.home, 'dist', 'build-provenance.json'); const temporary = `${target}.${randomUUID()}.tmp`; diff --git a/skills/sql-apps-getting-started/SKILL.md b/skills/sql-apps-getting-started/SKILL.md index 2bdcc9b..13a339d 100644 --- a/skills/sql-apps-getting-started/SKILL.md +++ b/skills/sql-apps-getting-started/SKILL.md @@ -25,9 +25,9 @@ Run `npm run guide` to recover saved decisions. Saved evidence is historical, no - Use `sql-apps-application` for screens, SQL behavior, access rules and the first useful change. - Use `sql-apps-local` for prerequisites, approval, startup, resume and stop. - Use `sql-apps-validation` for tests and live workflow checks. -- Use `sql-apps-design` for a substantial visual direction or redesign. +- Use an available frontend design skill for general visual direction; retain SQL Apps account/access and data-state requirements. - Use `sql-apps-diagnostics` for an existing failure. -- Use `sql-apps-cloud-preview` only after the user asks to share. +- Use `sql-apps-cloud-preview` only after the user asks to share; it routes general Azure preparation, cost, deployment, and diagnostics to maintained Azure skills. Do not preload those skills or their references. Load the next stage only, and stop gathering context once its command or blocker is known. diff --git a/sql-apps.example.json b/sql-apps.example.json index a064f0f..aa5ea28 100644 --- a/sql-apps.example.json +++ b/sql-apps.example.json @@ -1,15 +1,17 @@ { + "profile": "role-based-data", + "requiredRole": "AppUser", + "readinessPath": "/api/AppReady", "subscriptionId": "11111111-1111-4111-8111-111111111111", "tenantId": "22222222-2222-4222-8222-222222222222", "apiClientId": "33333333-3333-4333-8333-333333333333", "sqlAdminObjectId": "44444444-4444-4444-8444-444444444444", "sqlAdminName": "sql-apps-migration-admin", "resourceGroup": "rg-sql-apps-dev", - "location": "eastus", + "location": "westus2", "environment": "dev", "name": "sqlapps", - "gatewayImage": "example.azurecr.io/sql-apps:0.1.2", - "dabImage": "example.azurecr.io/sql-apps-dab:0.1.2", - "functionsImage": "example.azurecr.io/sql-apps-functions:0.1.2", + "gatewayImage": "example.azurecr.io/sql-apps:0.2.0", + "dabImage": "example.azurecr.io/sql-apps-dab:0.2.0", "registryServer": "example.azurecr.io" } diff --git a/sql/grant-runtime.sql b/sql/grant-runtime.sql index dc63ce6..13777eb 100644 --- a/sql/grant-runtime.sql +++ b/sql/grant-runtime.sql @@ -13,11 +13,8 @@ END ELSE IF (SELECT sid FROM sys.database_principals WHERE name = N'sql_apps_dab') <> @sid THROW 50002, 'Existing DAB user belongs to a different identity; review before changing permissions.', 1; +-- The exact EXECUTE / VIEW DEFINITION grants for every DAB procedure entity, generated from dab/dab-config.json. DECLARE @roleBasedGrants nvarchar(max) = N'$(RoleBasedProcedureGrants)'; -IF LEN(@roleBasedGrants) > 0 - EXEC sys.sp_executesql @roleBasedGrants; -ELSE -BEGIN - GRANT SELECT ON dbo.FileJobs TO [sql_apps_dab]; - GRANT VIEW DEFINITION ON dbo.FileJobs TO [sql_apps_dab]; -END +IF LEN(@roleBasedGrants) = 0 + THROW 50003, 'RoleBasedProcedureGrants is required; publish through SQL Apps or apply dist/sql/role-based-grants.sql.', 1; +EXEC sys.sp_executesql @roleBasedGrants; \ No newline at end of file diff --git a/sql/local/acceptance.sql b/sql/local/acceptance.sql index 89f9c32..ba34731 100644 --- a/sql/local/acceptance.sql +++ b/sql/local/acceptance.sql @@ -1,61 +1,8 @@ SET NOCOUNT ON; -SET XACT_ABORT OFF; IF USER_NAME() <> N'$(LocalDabUser)' THROW 51000, 'Acceptance must use the least-privilege app identity.', 1; IF IS_ROLEMEMBER(N'db_owner') = 1 OR HAS_PERMS_BY_NAME(NULL, NULL, 'CONTROL') = 1 THROW 51001, 'App identity must not have administrative permissions.', 1; - -DECLARE @alice nvarchar(36) = CONVERT(nvarchar(36), NEWID()); -DECLARE @bob nvarchar(36) = CONVERT(nvarchar(36), NEWID()); -DECLARE @id uniqueidentifier = NEWID(); -BEGIN TRANSACTION; -BEGIN TRY - EXEC sys.sp_set_session_context @key=N'oid', @value=@alice; - EXEC sys.sp_set_session_context @key=N'scp', @value=N'access_as_user'; - INSERT dbo.FileJobs(id,filename,blob_key) VALUES (@id,N'acceptance.txt',N'acceptance-snapshot'); - IF NOT EXISTS (SELECT 1 FROM dbo.FileJobs WHERE id=@id AND owner_oid=@alice AND status=N'queued') - THROW 51011, 'Job owner default/read failed.', 1; - UPDATE dbo.FileJobs SET status=N'completed' WHERE id=@id; - IF @@ROWCOUNT <> 1 THROW 51012, 'Job owner update failed.', 1; - - EXEC sys.sp_set_session_context @key=N'oid', @value=@bob; - IF EXISTS (SELECT 1 FROM dbo.FileJobs WHERE id=@id) - THROW 51013, 'Cross-user job read exposed a row.', 1; - UPDATE dbo.FileJobs SET status=N'failed' WHERE id=@id; - IF @@ROWCOUNT <> 0 THROW 51014, 'Cross-user job update succeeded.', 1; - DELETE dbo.FileJobs WHERE id=@id; - IF @@ROWCOUNT <> 0 THROW 51015, 'Cross-user job delete succeeded.', 1; - DECLARE @jobBlocked bit=0; - BEGIN TRY - INSERT dbo.FileJobs(id,owner_oid,filename,blob_key) VALUES (NEWID(),@alice,N'forbidden',N'forbidden'); - END TRY - BEGIN CATCH - IF ERROR_NUMBER() <> 33504 THROW; - SET @jobBlocked=1; - END CATCH; - IF @jobBlocked=0 THROW 51016, 'Cross-owner job insert bypassed the block predicate.', 1; - - - EXEC sys.sp_set_session_context @key=N'oid', @value=@alice; - SET @jobBlocked=0; - BEGIN TRY - UPDATE dbo.FileJobs SET owner_oid=@bob WHERE id=@id; - END TRY - BEGIN CATCH - IF ERROR_NUMBER() <> 33504 THROW; - SET @jobBlocked=1; - END CATCH; - IF @jobBlocked=0 THROW 51017, 'Job owner reassignment bypassed the block predicate.', 1; - EXEC sys.sp_set_session_context @key=N'scp', @value=N'other_scope'; - IF EXISTS (SELECT 1 FROM dbo.FileJobs WHERE id=@id) - THROW 51018, 'Missing delegated scope exposed job data.', 1; - EXEC sys.sp_set_session_context @key=N'scp', @value=N'access_as_user'; - DELETE dbo.FileJobs WHERE id=@id; - IF @@ROWCOUNT <> 1 THROW 51019, 'Owner job delete failed.', 1; - ROLLBACK; - PRINT N'LOCAL SQL ACCEPTANCE PASSED'; -END TRY -BEGIN CATCH - IF @@TRANCOUNT > 0 ROLLBACK; - THROW; -END CATCH; +IF IS_ROLEMEMBER(N'db_datareader') = 1 OR IS_ROLEMEMBER(N'db_datawriter') = 1 + THROW 51002, 'App identity must reach data only through granted procedures, not database-wide read or write roles.', 1; +PRINT N'LOCAL SQL ACCEPTANCE PASSED'; \ No newline at end of file diff --git a/sql/migrations/002-file-jobs.sql b/sql/migrations/002-file-jobs.sql deleted file mode 100644 index 8db6866..0000000 --- a/sql/migrations/002-file-jobs.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TABLE dbo.FileJobs ( - id uniqueidentifier NOT NULL CONSTRAINT PK_FileJobs PRIMARY KEY, - owner_oid nvarchar(36) NOT NULL DEFAULT CONVERT(nvarchar(36), SESSION_CONTEXT(N'oid')), - filename nvarchar(128) NOT NULL, - blob_key nvarchar(256) NOT NULL, - status nvarchar(16) NOT NULL DEFAULT N'queued', - sha256 nvarchar(64) NULL, - byte_count int NULL, - line_count int NULL, - error nvarchar(200) NULL, - created_at datetime2 NOT NULL DEFAULT SYSUTCDATETIME(), - updated_at datetime2 NOT NULL DEFAULT SYSUTCDATETIME(), - parent_job_id uniqueidentifier NULL, - CONSTRAINT CK_FileJobs_Status CHECK (status IN (N'queued', N'processing', N'completed', N'failed')) -); -GO -CREATE INDEX IX_FileJobs_Owner ON dbo.FileJobs(owner_oid, created_at); -GO -CREATE SECURITY POLICY dbo.FileJobOwnerPolicy -ADD FILTER PREDICATE dbo.OwnerPredicate(owner_oid) ON dbo.FileJobs, -ADD BLOCK PREDICATE dbo.OwnerPredicate(owner_oid) ON dbo.FileJobs AFTER INSERT, -ADD BLOCK PREDICATE dbo.OwnerPredicate(owner_oid) ON dbo.FileJobs AFTER UPDATE -WITH (STATE = ON); diff --git a/sql/procedures/AppReady.sql b/sql/procedures/AppReady.sql new file mode 100644 index 0000000..13024df --- /dev/null +++ b/sql/procedures/AppReady.sql @@ -0,0 +1,5 @@ +-- Read-only readiness procedure behind role-based-data.json readinessPath. Replace or extend with your own procedures. +CREATE PROCEDURE dbo.AppReady +AS +SET NOCOUNT ON; +SELECT CAST(1 AS bit) AS ready, DB_NAME() AS [database], SYSUTCDATETIME() AS checked_at; \ No newline at end of file diff --git a/src/architecture-catalog.ts b/src/architecture-catalog.ts deleted file mode 100644 index e004dd3..0000000 --- a/src/architecture-catalog.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { createReadonlyModuleCatalog } from './architecture-module.js'; - -export const mandatoryArchitectureModules = ['azure-sql-free', 'dab'] as const; - -const descriptors = [ - { - id: 'azure-sql-free', - version: 1, - surface: 2, - provides: ['sql'], - requiresCapabilities: [], - requiresModules: [], - incompatibleModules: [], - guarantees: ['no-paid-sql-fallback'], - resources: [ - { - type: 'Microsoft.Sql/servers', - purpose: 'Provides the logical server for the free Azure SQL Database', - }, - { - type: 'Microsoft.Sql/servers/databases', - sku: 'GP_S_Gen5_2', - purpose: 'Stores relational data with free enrollment and no paid fallback', - }, - ], - acceptanceChecks: ['verify-sql-free-enrollment'], - growthTransitions: [], - }, - { - id: 'dab', - version: 1, - surface: 0, - provides: ['data-api'], - requiresCapabilities: ['dab-runtime'], - requiresModules: ['azure-sql-free'], - incompatibleModules: [], - guarantees: ['database-api-boundary'], - resources: [], - acceptanceChecks: ['verify-dab-data-api-contract'], - growthTransitions: [], - }, - { - id: 'container-apps-dab-runtime', - version: 1, - surface: 2, - provides: ['dab-runtime'], - requiresCapabilities: [], - requiresModules: ['managed-identity', 'container-images'], - incompatibleModules: [], - guarantees: ['managed-identity-runtime'], - resources: [ - { - type: 'Microsoft.App/managedEnvironments', - purpose: 'Provides the shared Container Apps runtime environment', - }, - { - type: 'Microsoft.App/containerApps', - purpose: 'Hosts Data API builder runtime metadata', - }, - ], - acceptanceChecks: ['verify-dab-runtime-metadata'], - growthTransitions: [], - }, - { - id: 'managed-identity', - version: 1, - surface: 1, - provides: ['managed-identity'], - requiresCapabilities: [], - requiresModules: [], - incompatibleModules: [], - guarantees: ['passwordless-resource-access'], - resources: [{ - type: 'Microsoft.ManagedIdentity/userAssignedIdentities', - purpose: 'Identifies application workloads without application secrets', - }], - acceptanceChecks: ['verify-managed-identity-contract'], - growthTransitions: [], - }, - { - id: 'container-images', - version: 1, - surface: 1, - provides: ['container-images'], - requiresCapabilities: [], - requiresModules: [], - incompatibleModules: [], - guarantees: ['private-image-source'], - resources: [{ - type: 'Microsoft.ContainerRegistry/registries', - sku: 'Basic', - purpose: 'Stores application container images', - }], - acceptanceChecks: ['verify-container-image-source'], - growthTransitions: [], - }, - { - id: 'static-web', - version: 1, - surface: 1, - provides: ['static-web'], - requiresCapabilities: [], - requiresModules: [], - incompatibleModules: [], - guarantees: ['static-content-only'], - resources: [{ - type: 'Microsoft.Web/staticSites', - sku: 'Free', - purpose: 'Hosts the static browser application', - }], - acceptanceChecks: ['verify-static-web-contract'], - growthTransitions: [], - }, - { - id: 'node-api', - version: 1, - surface: 0, - provides: ['application-api'], - requiresCapabilities: ['node-runtime'], - requiresModules: [], - incompatibleModules: [], - guarantees: ['application-api-boundary'], - resources: [], - acceptanceChecks: ['verify-application-api-contract'], - growthTransitions: [], - }, - { - id: 'container-apps-node-runtime', - version: 1, - surface: 2, - provides: ['node-runtime'], - requiresCapabilities: [], - requiresModules: ['managed-identity', 'container-images'], - incompatibleModules: [], - guarantees: ['managed-identity-runtime'], - resources: [ - { - type: 'Microsoft.App/managedEnvironments', - purpose: 'Provides the shared Container Apps runtime environment', - }, - { - type: 'Microsoft.App/containerApps', - purpose: 'Hosts Node.js application API runtime metadata', - }, - ], - acceptanceChecks: ['verify-node-runtime-metadata'], - growthTransitions: [], - }, - { - id: 'visitor-session-api', - version: 1, - surface: 0, - provides: ['visitor-sessions'], - requiresCapabilities: [], - requiresModules: ['node-api'], - incompatibleModules: [], - guarantees: ['visitor-session-boundary'], - resources: [], - acceptanceChecks: ['verify-visitor-session-contract'], - growthTransitions: [], - }, - { - id: 'entra-identity', - version: 1, - surface: 1, - provides: ['entra-auth'], - requiresCapabilities: [], - requiresModules: ['node-api'], - incompatibleModules: [], - guarantees: ['authenticated-user-boundary'], - resources: [{ - type: 'Microsoft.Graph/applications', - purpose: 'Describes the Microsoft Entra application identity contract', - }], - acceptanceChecks: ['verify-entra-auth-contract'], - growthTransitions: [], - }, - { - id: 'files', - version: 1, - surface: 2, - provides: ['files'], - requiresCapabilities: [], - requiresModules: ['node-api', 'managed-identity'], - incompatibleModules: [], - guarantees: ['managed-identity-storage-access'], - resources: [ - { - type: 'Microsoft.Storage/storageAccounts', - purpose: 'Provides the shared storage account for application data', - }, - { - type: 'Microsoft.Storage/storageAccounts/blobServices', - purpose: 'Stores application files in blob storage', - }, - ], - acceptanceChecks: ['verify-file-storage-contract'], - growthTransitions: [], - }, - { - id: 'background-jobs', - version: 1, - surface: 2, - provides: ['background-jobs'], - requiresCapabilities: [], - requiresModules: ['node-api', 'managed-identity'], - incompatibleModules: [], - guarantees: ['managed-identity-queue-access'], - resources: [ - { - type: 'Microsoft.Storage/storageAccounts', - purpose: 'Provides the shared storage account for application data', - }, - { - type: 'Microsoft.Storage/storageAccounts/queueServices', - purpose: 'Queues asynchronous application work', - }, - ], - acceptanceChecks: ['verify-background-job-contract'], - growthTransitions: [], - }, - { - id: 'log-analytics', - version: 1, - surface: 1, - provides: ['operational-observability'], - requiresCapabilities: [], - requiresModules: [], - incompatibleModules: [], - guarantees: ['centralized-platform-logs'], - resources: [{ - type: 'Microsoft.OperationalInsights/workspaces', - purpose: 'Collects platform logs for operational analysis', - }], - acceptanceChecks: ['verify-operational-observability-contract'], - growthTransitions: [], - }, -] as const; - -export const architectureCatalog = createReadonlyModuleCatalog(descriptors); diff --git a/src/architecture-compiler.ts b/src/architecture-compiler.ts deleted file mode 100644 index c86c0af..0000000 --- a/src/architecture-compiler.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { createHash } from 'node:crypto'; -import { - architectureCatalog, - mandatoryArchitectureModules, -} from './architecture-catalog.js'; -import { - architectureIntentSchema, - canonical, -} from './architecture-contract.js'; -import type { - ReadonlyArchitectureModule, - ReadonlyArchitectureModuleCatalog, -} from './architecture-module.js'; - -export interface CompiledArchitectureResource { - type: string; - sku?: string; - purpose: string; - module: string; -} - -export interface CompiledArchitecturePlan { - version: 1; - briefFingerprint: string; - optimization: 'simplest-validated-start'; - mandatoryCore: string[]; - modules: string[]; - resources: CompiledArchitectureResource[]; - guarantees: string[]; - acceptanceChecks: string[]; - growth: string[]; - limitations: string[]; - authorized: false; - deployable: false; - approval: string; -} - -type PlanWithoutApproval = Omit; - -interface SearchFailure { - unsupported?: string; - incompatibility?: string; -} - -const compareText = (left: string, right: string): number => - left < right ? -1 : left > right ? 1 : 0; - -export function compileArchitecture( - input: unknown, - catalog: ReadonlyArchitectureModuleCatalog = architectureCatalog, -): CompiledArchitecturePlan { - const parsed = architectureIntentSchema.parse(input); - const requestedCapabilities = [...parsed.capabilities].sort(compareText); - const requestedGuarantees = [...parsed.guarantees].sort(compareText); - const requestedGrowth = [...parsed.growth].sort(compareText); - const providers = indexProviders(catalog); - const failures: SearchFailure = {}; - const complete: Set[] = []; - const visited = new Set(); - - const initial = closeRequiredModules( - new Set(), - mandatoryArchitectureModules, - catalog, - ); - - const search = (selected: Set): void => { - const stateKey = [...selected].sort(compareText).join('\0'); - if (visited.has(stateKey)) return; - visited.add(stateKey); - - const requiredCapabilities = new Set(requestedCapabilities); - for (const id of selected) { - const module = requireModule(catalog, id); - for (const capability of module.requiresCapabilities) { - requiredCapabilities.add(capability); - } - } - - const missing = [...requiredCapabilities] - .filter(capability => !providesCapability(selected, capability, catalog)) - .sort(compareText)[0]; - if (missing === undefined) { - complete.push(selected); - return; - } - - const candidates = providers.get(missing) ?? []; - if (candidates.length === 0) { - failures.unsupported ??= missing; - return; - } - - for (const candidate of candidates) { - try { - search(closeRequiredModules(new Set(selected), [candidate.id], catalog)); - } catch (error) { - if (error instanceof Error) failures.incompatibility ??= error.message; - else throw error; - } - } - }; - - search(initial); - - if (complete.length === 0) { - if (failures.unsupported !== undefined) { - throw new Error(`No supported module provides ${failures.unsupported}`); - } - throw new Error(failures.incompatibility ?? 'No complete compatible architecture composition exists'); - } - - complete.sort((left, right) => compareCompositions(left, right, catalog)); - const selectedIds = [...requireSelection(complete[0])].sort(compareText); - const selectedModules = selectedIds.map(id => requireModule(catalog, id)); - const withoutApproval: PlanWithoutApproval = { - version: 1, - briefFingerprint: parsed.briefFingerprint, - optimization: 'simplest-validated-start', - mandatoryCore: [...mandatoryArchitectureModules], - modules: selectedIds, - resources: compileResources(selectedModules), - guarantees: sortedUnique([ - ...requestedGuarantees, - ...selectedModules.flatMap(module => module.guarantees), - ]), - acceptanceChecks: sortedUnique( - selectedModules.flatMap(module => module.acceptanceChecks), - ), - growth: sortedUnique([ - ...requestedGrowth, - ...selectedModules.flatMap(module => module.growthTransitions), - ]), - limitations: [ - 'Azure eligibility unverified', - 'Costs not quoted', - 'Modules metadata-only and review-only', - 'No Azure writes authorized', - ], - authorized: false, - deployable: false, - }; - - return { - ...withoutApproval, - approval: createHash('sha256') - .update(canonical(withoutApproval)) - .digest('hex') - .slice(0, 12), - }; -} - -function indexProviders( - catalog: ReadonlyArchitectureModuleCatalog, -): Map { - const providers = new Map(); - for (const module of catalog.values()) { - for (const capability of module.provides) { - const entries = providers.get(capability) ?? []; - entries.push(module); - providers.set(capability, entries); - } - } - for (const entries of providers.values()) { - entries.sort((left, right) => compareText(left.id, right.id)); - } - return providers; -} - -function closeRequiredModules( - selected: Set, - roots: readonly string[], - catalog: ReadonlyArchitectureModuleCatalog, -): Set { - const add = (id: string): void => { - if (selected.has(id)) return; - const module = requireModule(catalog, id); - for (const selectedId of selected) { - const existing = requireModule(catalog, selectedId); - if ( - module.incompatibleModules.includes(selectedId) - || existing.incompatibleModules.includes(id) - ) { - const pair = [id, selectedId].sort(compareText); - throw new Error(`Incompatible modules: ${pair[0]} and ${pair[1]}`); - } - } - selected.add(id); - for (const requiredId of [...module.requiresModules].sort(compareText)) add(requiredId); - }; - - for (const id of [...roots].sort(compareText)) add(id); - return selected; -} - -function providesCapability( - selected: ReadonlySet, - capability: string, - catalog: ReadonlyArchitectureModuleCatalog, -): boolean { - for (const id of selected) { - if (requireModule(catalog, id).provides.includes(capability)) return true; - } - return false; -} - -function compareCompositions( - left: ReadonlySet, - right: ReadonlySet, - catalog: ReadonlyArchitectureModuleCatalog, -): number { - if (left.size !== right.size) return left.size - right.size; - const leftSurface = compositionSurface(left, catalog); - const rightSurface = compositionSurface(right, catalog); - if (leftSurface !== rightSurface) return leftSurface - rightSurface; - return compareText( - [...left].sort(compareText).join(','), - [...right].sort(compareText).join(','), - ); -} - -function compositionSurface( - selected: ReadonlySet, - catalog: ReadonlyArchitectureModuleCatalog, -): number { - let surface = 0; - for (const id of selected) surface += requireModule(catalog, id).surface; - return surface; -} - -function compileResources( - modules: readonly ReadonlyArchitectureModule[], -): CompiledArchitectureResource[] { - const resources = new Map(); - for (const module of modules) { - for (const resource of module.resources) { - const key = canonical({ - type: resource.type, - sku: resource.sku, - purpose: resource.purpose, - }); - if (!resources.has(key)) { - resources.set(key, { - type: resource.type, - ...(resource.sku === undefined ? {} : { sku: resource.sku }), - purpose: resource.purpose, - module: module.id, - }); - } - } - } - return [...resources.values()].sort((left, right) => compareText( - [left.type, left.sku ?? '', left.purpose, left.module].join('\0'), - [right.type, right.sku ?? '', right.purpose, right.module].join('\0'), - )); -} - -function sortedUnique(values: readonly string[]): string[] { - return [...new Set(values)].sort(compareText); -} - -function requireModule( - catalog: ReadonlyArchitectureModuleCatalog, - id: string, -): ReadonlyArchitectureModule { - const module = catalog.get(id); - if (!module) throw new Error(`Architecture catalog is missing required module ${id}`); - return module; -} - -function requireSelection( - selection: Set | undefined, -): Set { - if (!selection) throw new Error('No complete architecture composition exists'); - return selection; -} diff --git a/src/architecture-contract.ts b/src/architecture-contract.ts deleted file mode 100644 index d9eb9a8..0000000 --- a/src/architecture-contract.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { createHash } from 'node:crypto'; -import { z } from 'zod'; - -export const capabilitySchema = z.enum([ - 'static-web', - 'server-rendered', - 'application-api', - 'visitor-sessions', - 'entra-auth', - 'files', - 'background-jobs', - 'operational-observability', -]); -export type ArchitectureCapability = z.infer; - -export const guaranteeSchema = z.enum([ - 'synthetic-data-only', - 'same-origin-browser', - 'authenticated-users', - 'private-data-path', - 'operational-investigation', -]); -export type ArchitectureGuarantee = z.infer; - -export const applicationBriefSchema = z.strictObject({ - version: z.literal(1), - audience: z.enum(['private', 'invited', 'organization', 'public']), - access: z.enum(['anonymous', 'visitor-session', 'entra']), - frontend: z.enum(['static', 'server-rendered']), - api: z.enum(['dab-only', 'application']), - data: z.enum(['synthetic', 'real']), - files: z.boolean(), - backgroundWork: z.boolean(), - availability: z.enum(['proof-of-concept', 'shared-preview', 'relied-upon']), - observability: z.enum(['basic', 'operational']), - growthPriority: z.enum(['simplest-start', 'continuity', 'stronger-isolation']), - acceptanceActions: z.array(z.string().trim().min(1).max(256)).min(1).max(20), -}).superRefine((brief, context) => { - if (brief.access === 'anonymous' && brief.data === 'real') { - context.addIssue({ - code: 'custom', - path: ['data'], - message: 'Anonymous access is limited to synthetic data', - }); - } - if (brief.frontend === 'server-rendered' && brief.api === 'dab-only') { - context.addIssue({ - code: 'custom', - path: ['api'], - message: 'Server-rendered applications require application API logic', - }); - } -}); -export type ApplicationBrief = z.infer; - -const unique = >(item: T) => - z.array(item).min(1).refine(values => new Set(values).size === values.length, 'Values must be unique'); - -export const architectureIntentSchema = z.strictObject({ - version: z.literal(1), - briefFingerprint: z.string().regex(/^[a-f0-9]{64}$/), - capabilities: unique(capabilitySchema), - guarantees: z.array(guaranteeSchema) - .refine(values => new Set(values).size === values.length, 'Values must be unique'), - optimization: z.literal('simplest-validated-start'), - growth: z.array(z.string().regex(/^[a-z][a-z0-9-]{1,63}$/)) - .refine(values => new Set(values).size === values.length, 'Values must be unique'), -}); -export type ArchitectureIntent = z.infer; - -export function canonical(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; - if (value && typeof value === 'object') { - return `{${Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) - .map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(',')}}`; - } - return JSON.stringify(value); -} - -export function briefFingerprint(input: ApplicationBrief): string { - const brief = applicationBriefSchema.parse(input); - return createHash('sha256').update(canonical(brief)).digest('hex'); -} diff --git a/src/architecture-module.ts b/src/architecture-module.ts deleted file mode 100644 index bace764..0000000 --- a/src/architecture-module.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { z } from 'zod'; - -const nameSchema = z.string() - .regex(/^[a-z][a-z0-9-]{1,63}$/); -const uniqueNames = (minimum = 0) => z.array(nameSchema) - .min(minimum) - .refine(values => new Set(values).size === values.length, 'Names must be unique'); - -export const architectureModuleSchema = z.strictObject({ - id: nameSchema, - version: z.number().int().positive(), - surface: z.number().int().nonnegative(), - provides: uniqueNames(1), - requiresCapabilities: uniqueNames(), - requiresModules: uniqueNames(), - incompatibleModules: uniqueNames(), - guarantees: uniqueNames(), - resources: z.array(z.strictObject({ - type: z.string().trim().min(1), - sku: z.string().trim().min(1).optional(), - purpose: z.string().trim().min(1), - })), - acceptanceChecks: uniqueNames(1), - growthTransitions: uniqueNames(), -}); - -export type ArchitectureModule = z.infer; -export type ArchitectureModuleCatalog = Map; -export type DeepReadonly = - T extends (...args: never[]) => unknown ? T - : T extends readonly (infer Item)[] ? readonly DeepReadonly[] - : T extends object ? { readonly [Key in keyof T]: DeepReadonly } - : T; -export type ReadonlyArchitectureModule = DeepReadonly; -export type ReadonlyArchitectureModuleCatalog = - ReadonlyMap; - -export function validateModuleCatalog(input: readonly unknown[]): ArchitectureModuleCatalog { - const modules = z.array(architectureModuleSchema).parse(input); - const byId = new Map(); - - for (const module of modules) { - if (byId.has(module.id)) throw new Error(`Duplicate module ID: ${module.id}`); - byId.set(module.id, module); - } - - const catalog = new Map([...byId.entries()].sort(([left], [right]) => - left < right ? -1 : left > right ? 1 : 0)); - - for (const module of catalog.values()) { - validateReferences(catalog, module, module.requiresModules, 'required module'); - validateReferences(catalog, module, module.incompatibleModules, 'incompatible module'); - validateReferences(catalog, module, module.growthTransitions, 'growth transition'); - - const incompatible = new Set(module.incompatibleModules); - const conflict = module.requiresModules.find(id => incompatible.has(id)); - if (conflict) { - throw new Error(`Module ${module.id} both requires and rejects module ${conflict}`); - } - } - - detectRequiredModuleCycles(catalog); - return catalog; -} - -export function createReadonlyModuleCatalog( - input: readonly unknown[], -): ReadonlyArchitectureModuleCatalog { - const catalog = validateModuleCatalog(input); - for (const module of catalog.values()) deepFreeze(module); - return new LockedModuleCatalog(catalog); -} - -class LockedModuleCatalog implements ReadonlyArchitectureModuleCatalog { - readonly #catalog: ArchitectureModuleCatalog; - - constructor(catalog: ArchitectureModuleCatalog) { - this.#catalog = catalog; - Object.freeze(this); - } - - get size(): number { - return this.#catalog.size; - } - - entries(): MapIterator<[string, ReadonlyArchitectureModule]> { - return this.#catalog.entries(); - } - - forEach( - callbackfn: ( - value: ReadonlyArchitectureModule, - key: string, - map: ReadonlyArchitectureModuleCatalog, - ) => void, - thisArg?: unknown, - ): void { - for (const [key, value] of this.#catalog) callbackfn.call(thisArg, value, key, this); - } - - get(key: string): ReadonlyArchitectureModule | undefined { - return this.#catalog.get(key); - } - - has(key: string): boolean { - return this.#catalog.has(key); - } - - keys(): MapIterator { - return this.#catalog.keys(); - } - - values(): MapIterator { - return this.#catalog.values(); - } - - [Symbol.iterator](): MapIterator<[string, ReadonlyArchitectureModule]> { - return this.entries(); - } -} - -function deepFreeze(value: T): DeepReadonly { - if (value && typeof value === 'object' && !Object.isFrozen(value)) { - for (const child of Object.values(value)) deepFreeze(child); - Object.freeze(value); - } - return value as DeepReadonly; -} - -function validateReferences( - catalog: ArchitectureModuleCatalog, - module: ArchitectureModule, - references: string[], - kind: string, -): void { - const unknown = references.find(id => !catalog.has(id)); - if (unknown) throw new Error(`Module ${module.id} references unknown ${kind}: ${unknown}`); -} - -function detectRequiredModuleCycles(catalog: ArchitectureModuleCatalog): void { - const visited = new Set(); - const path: string[] = []; - const active = new Map(); - - const visit = (id: string): void => { - const cycleStart = active.get(id); - if (cycleStart !== undefined) { - throw new Error(`Required-module dependency cycle: ${[...path.slice(cycleStart), id].join(' -> ')}`); - } - if (visited.has(id)) return; - - active.set(id, path.length); - path.push(id); - const module = catalog.get(id); - if (!module) throw new Error(`Unknown module during dependency traversal: ${id}`); - for (const requiredId of module.requiresModules) visit(requiredId); - path.pop(); - active.delete(id); - visited.add(id); - }; - - for (const id of catalog.keys()) visit(id); -} diff --git a/src/artifacts.ts b/src/artifacts.ts index 323847b..da739f5 100644 --- a/src/artifacts.ts +++ b/src/artifacts.ts @@ -4,10 +4,7 @@ import { roleBasedSchemaFingerprint } from './role-based-profile.js'; import { realpath } from 'node:fs/promises'; import { toolingAsset } from './runtime-roots.js'; -export const imageFields = ['gatewayImage', 'dabImage', 'functionsImage'] as const; -export function deploymentImageFields(config: DeploymentConfig) { - return config.profile === 'role-based-data' ? imageFields.slice(0, 2) : [...imageFields]; -} +export const imageFields = ['gatewayImage', 'dabImage'] as const; export const pinnedDabImage = 'mcr.microsoft.com/azure-databases/data-api-builder:2.0.12@sha256:85db5c7f1af9d0bc93af824a0602285880a07dba210854bc68394e08d9d338ac'; export async function imageDigest(image: string, config: DeploymentConfig, run: Run): Promise { @@ -21,31 +18,28 @@ export async function imageDigest(image: string, config: DeploymentConfig, run: return `${config.registryServer}/${repository}@${digest}`; } -export async function buildArtifacts(config: DeploymentConfig, run: Run): Promise { - const fields = deploymentImageFields(config); +export async function buildArtifacts(config: DeploymentConfig, run: Run, now = new Date()): Promise { + const fields = imageFields; const root = await realpath(process.env.SQL_APPS_PROJECT_HOME ?? process.cwd()); - const source = config.profile === 'role-based-data' ? await roleBasedSchemaFingerprint(root) : undefined; - if (fields.some(field => config[field].includes('@'))) { - throw new Error('Artifact builds require new version tags for every selected image, not digests'); - } - if (new Set(fields.map(field => config[field])).size !== fields.length) { + const source = await roleBasedSchemaFingerprint(root); + // Pinned images are rebuilt under a fresh timestamp tag so an update never overwrites a published tag. + const tag = now.toISOString().replace(/[-:]/g, '').replace(/\..*$/, '').replace('T', '-').toLowerCase(); + const target = { ...config }; + for (const field of fields) if (config[field].includes('@')) target[field] = `${config[field].split('@')[0]!.replace(/:[^:/]+$/, '')}:${tag}`; + if (new Set(fields.map(field => target[field])).size !== fields.length) { throw new Error('Artifact builds require distinct references for every selected service image'); } await run('az', ['acr', 'show', '--subscription', config.subscriptionId, '--resource-group', config.resourceGroup, '--name', config.registryServer.split('.')[0]!, '-o', 'none']); - const dockerfiles = [ - toolingAsset('Dockerfile'), - toolingAsset('dab', 'Dockerfile'), - toolingAsset('functions', 'Dockerfile'), - ]; - const pinned = { ...config }; + const dockerfiles = [toolingAsset('Dockerfile'), toolingAsset('dab', 'Dockerfile')]; + const pinned = { ...config, imageSource: source }; for (const [index, field] of fields.entries()) { await run('az', ['acr', 'build', '--subscription', config.subscriptionId, - '--registry', config.registryServer.split('.')[0]!, '--image', config[field].slice(config.registryServer.length + 1), + '--registry', config.registryServer.split('.')[0]!, '--image', target[field].slice(config.registryServer.length + 1), '--file', dockerfiles[index]!, root]); - pinned[field] = await imageDigest(config[field], config, run); + pinned[field] = await imageDigest(target[field], config, run); } - if (source !== undefined && source !== await roleBasedSchemaFingerprint(root)) { + if (source !== await roleBasedSchemaFingerprint(root)) { throw new Error('Role-based SQL/DAB source changed during artifact publication; published images may exist but configuration was not updated'); } return deploymentSchema.parse(pinned); diff --git a/src/azure-init.ts b/src/azure-init.ts index 487e39b..40a4e81 100644 --- a/src/azure-init.ts +++ b/src/azure-init.ts @@ -10,7 +10,6 @@ import { readRoleBasedProfile } from './role-based-profile.js'; export const unsetClientId = '00000000-0000-0000-0000-000000000000'; export interface InitOptions { - profile: 'foundation' | 'role-based-data'; location: string; resourceGroup: string; registryServer: string; @@ -32,20 +31,17 @@ export async function initDeploymentConfig(options: InitOptions, run: Run, root .parse(JSON.parse(await run('az', ['account', 'show', '-o', 'json']))); if (account.user.type !== 'user') throw new Error('azure init needs a signed-in user (az login) to become the SQL administrator; service principals are not supported here.'); const operator = (await run('az', ['ad', 'signed-in-user', 'show', '--query', 'id', '-o', 'tsv'])).trim(); - let role: { requiredRole?: string; readinessPath?: string } = {}; - if (options.profile === 'role-based-data') { - try { const profile = await readRoleBasedProfile(join(root, 'role-based-data.json')); role = { requiredRole: profile.requiredRole, readinessPath: profile.readinessPath }; } - catch (error) { throw new Error(`Role-based deployment reads requiredRole and readinessPath from role-based-data.json: ${error instanceof Error ? error.message : error}`); } - } + let role: { requiredRole: string; readinessPath: string }; + try { const profile = await readRoleBasedProfile(join(root, 'role-based-data.json')); role = { requiredRole: profile.requiredRole, readinessPath: profile.readinessPath }; } + catch (error) { throw new Error(`Deployment reads requiredRole and readinessPath from role-based-data.json: ${error instanceof Error ? error.message : error}`); } const name = deploymentName(options.name ?? z.object({ name: z.string() }).parse(JSON.parse(await readFile(join(root, 'application.json'), 'utf8'))).name); - const image = (part: string) => `${options.registryServer}/${name}-${part}:0.1.2`; + const image = (part: string) => `${options.registryServer}/${name}-${part}:0.2.0`; return deploymentSchema.parse({ - profile: options.profile, ...role, + profile: 'role-based-data', ...role, subscriptionId: account.id, tenantId: account.tenantId, apiClientId: unsetClientId, sqlAdminObjectId: operator, sqlAdminName: account.user.name, resourceGroup: options.resourceGroup, location: options.location, environment: options.environment ?? 'dev', name, gatewayImage: image('gateway'), dabImage: image('data'), - functionsImage: options.profile === 'foundation' ? image('functions') : '', registryServer: options.registryServer, }); } @@ -55,12 +51,15 @@ export type DeployStep = typeof deploySteps[number]; export interface DeployAllPlan { pending: DeployStep[]; approval: string; excluded: string[] } -/** Lists the steps still needed; the approval id changes whenever the config or the remaining work changes. */ -export function planDeployAll(config: DeploymentConfig, stage: string | undefined): DeployAllPlan { - const pinned = [config.gatewayImage, config.dabImage, config.functionsImage].filter(Boolean).every(image => image.includes('@sha256:')); +/** + * Lists the steps still needed; the approval id changes whenever the config or the remaining work changes. + * Images built from older SQL/DAB source (`source` is the current fingerprint) make artifacts pending again. + */ +export function planDeployAll(config: DeploymentConfig, stage: string | undefined, source: string): DeployAllPlan { + const pinned = [config.gatewayImage, config.dabImage].every(image => image.includes('@sha256:')); const done: Record = { identity: config.apiClientId !== unsetClientId, - artifacts: pinned, + artifacts: pinned && config.imageSource === source, provision: stage !== undefined, deploy: stage === 'ready', status: false, @@ -68,8 +67,9 @@ export function planDeployAll(config: DeploymentConfig, stage: string | undefine const pending = deploySteps.filter(step => !done[step]); const approval = createHash('sha256').update(JSON.stringify({ config, pending })).digest('hex').slice(0, 12); return { pending, approval, excluded: [ + 'rbac-plan / rbac-deploy: Azure resource-role assignment needs a separately authorized administrator after provisioning', 'role-based-assign: assigning the application role names a person and stays a separate decision', - 'role-based-smoke / smoke: needs delegated user tokens', + 'role-based-smoke: needs delegated user tokens', 'deleting or changing existing resources outside this deployment', ] }; } diff --git a/src/azure-preflight.ts b/src/azure-preflight.ts deleted file mode 100644 index ce7558e..0000000 --- a/src/azure-preflight.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { z } from 'zod'; -import type { Run } from './process.js'; - -const guid = z.string().uuid().transform(value => value.toLowerCase()); -export const demoTargetSchema = z.strictObject({ - profile: z.literal('public-demo'), - securityPosture: z.enum(['starter', 'hardened']), - tenantId: guid, subscriptionId: guid, - resourceGroup: z.string().regex(/^[a-zA-Z0-9_().-]{1,90}$/).refine(value => !value.endsWith('.')), - location: z.string().regex(/^[a-z][a-z0-9]{1,31}$/), - staticWebAppLocation: z.string().regex(/^[a-z][a-z0-9]{1,31}$/).optional(), -}); -export type DemoTarget = z.infer; -type PreflightCode = 'TENANT_TOKEN_REQUIRED' | 'TOKEN_TENANT_MISMATCH' | 'TOKEN_EXPIRED' | 'TOKEN_FORMAT_INVALID' | - 'SUBSCRIPTION_TENANT_MISMATCH' | 'SUBSCRIPTION_NOT_ENABLED' | 'ARM_FORBIDDEN' | 'ARM_TOKEN_REJECTED' | - 'AZURE_POLICY_DENIED' | 'ARM_TRANSIENT_FAILURE' | 'ARM_TRANSPORT_FAILED' | 'ARM_RESOURCE_NOT_ACCESSIBLE' | - 'ARM_INVALID_RESPONSE' | 'PROVIDER_REGISTRATION_REQUIRED' | 'REGION_UNSUPPORTED' | 'WRITE_AUTHORIZATION_MISSING'; -export class DemoPreflightError extends Error { - readonly stage = 'azure-preflight'; - constructor(readonly code: PreflightCode, message: string) { - super(message); - this.name = 'DemoPreflightError'; - } -} -const baseProviders = { - 'Microsoft.App': ['managedEnvironments', 'containerApps'], - 'Microsoft.Sql': ['servers'], - 'Microsoft.Network': ['virtualNetworks'], - 'Microsoft.ContainerRegistry': ['registries'], - 'Microsoft.ManagedIdentity': ['userAssignedIdentities'], - 'Microsoft.Web': ['staticSites', 'staticSites/linkedBackends'], -} as const; - -async function cacheEvidence(target: DemoTarget, runner: Run) { - let text: string; - try { text = await runner('az', ['account', 'list', '--all', '--output', 'json']); } - catch { - return { status: 'unavailable' as const, code: 'CACHE_DISCOVERY_FAILED', - action: 'Cache discovery failed; independently test the selected tenant token and actual ARM access. Do not infer revoked access or require Graph.' }; - } - let body: unknown; - try { body = JSON.parse(text); } - catch { return { status: 'unavailable' as const, code: 'CACHE_RESPONSE_INVALID', action: 'Review CLI cache discovery separately from actual ARM access.' }; } - const parsed = z.array(z.object({ id: guid, tenantId: guid })).safeParse(body); - if (!parsed.success) return { status: 'unavailable' as const, code: 'CACHE_RESPONSE_INVALID', action: 'Review CLI cache discovery separately from actual ARM access.' }; - const match = parsed.data.find(account => account.id === target.subscriptionId); - return { status: match ? 'found' as const : 'not-found' as const, - code: match && match.tenantId !== target.tenantId ? 'CACHE_TENANT_MISMATCH' : 'CACHE_DISCOVERY_COMPLETE', - action: 'Cache evidence is not authoritative; this workflow does not refresh login or change global subscription defaults.' }; -} - -async function tenantToken(target: DemoTarget, runner: Run) { - let text: string; - try { - text = await runner('az', ['account', 'get-access-token', '--tenant', target.tenantId, - '--resource', 'https://management.azure.com/', '--output', 'json']); - } catch { - throw new DemoPreflightError('TENANT_TOKEN_REQUIRED', - 'Tenant-scoped CLI token acquisition failed. Review sign-in, expiration and conditional-access requirements for the selected tenant; this is not proof of revoked ARM access. Login/cache changes require approval.'); - } - let body: unknown; - try { body = JSON.parse(text); } - catch { throw new DemoPreflightError('TOKEN_FORMAT_INVALID', 'CLI token response is not valid JSON; token contents were not logged.'); } - const parsed = z.object({ accessToken: z.string().min(1), tenant: guid, - expires_on: z.union([z.number().int().positive(), z.string().regex(/^[0-9]+$/).transform(Number)]) }).safeParse(body); - if (!parsed.success) throw new DemoPreflightError('TOKEN_FORMAT_INVALID', 'CLI token metadata is invalid; a current CLI with UTC expires_on is required. Local-time expiresOn is not used.'); - if (parsed.data.tenant !== target.tenantId) throw new DemoPreflightError('TOKEN_TENANT_MISMATCH', 'CLI returned a token for another tenant; no ARM request was issued.'); - if (parsed.data.expires_on * 1000 <= Date.now() + 30_000) throw new DemoPreflightError('TOKEN_EXPIRED', 'Tenant token is expired or too close to expiration; renew it explicitly before retrying.'); - return parsed.data.accessToken; -} - -function parse(schema: z.ZodType, body: unknown, operation: string): T { - const result = schema.safeParse(body); - if (!result.success) throw new DemoPreflightError('ARM_INVALID_RESPONSE', `ARM ${operation} returned an invalid response; raw contents were not logged.`); - return result.data; -} -const delay = (attempt: number) => new Promise(done => setTimeout(done, 100 * 2 ** attempt)); -function transientTransport(error: unknown) { - if (!(error instanceof Error)) return false; - if (error.name === 'TimeoutError') return true; - const cause = error.cause; - return cause instanceof Error && 'code' in cause && - ['ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN'].includes(String(cause.code)); -} -function actionMatches(pattern: string, action: string) { - const expression = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*'); - return new RegExp(`^${expression}$`, 'i').test(action); -} - -export async function preflightDemo(input: unknown, runner: Run, fetcher: typeof fetch = fetch) { - const target = demoTargetSchema.parse(input); - const providers = target.securityPosture === 'hardened' - ? { ...baseProviders, 'Microsoft.Authorization': ['roleAssignments'] as const } - : baseProviders; - const [cache, token] = await Promise.all([cacheEvidence(target, runner), tenantToken(target, runner)]); - const subscriptionScope = `/subscriptions/${target.subscriptionId}`; - const groupScope = `${subscriptionScope}/resourcegroups/${encodeURIComponent(target.resourceGroup)}`; - const arm = async (path: string, version: string, allowMissing = false): Promise => { - const url = new URL(path, 'https://management.azure.com'); - url.searchParams.set('api-version', version); - for (let attempt = 0; attempt < 3; attempt++) { - let response: Response; - try { - response = await fetcher(url, { - headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15_000), redirect: 'error', - }); - } catch (error) { - if (transientTransport(error) && attempt < 2) { await delay(attempt); continue; } - throw new DemoPreflightError(transientTransport(error) ? 'ARM_TRANSIENT_FAILURE' : 'ARM_TRANSPORT_FAILED', - 'The selected ARM endpoint could not be reached; inspect transport/TLS evidence separately from login or access.'); - } - if ([429, 500, 502, 503, 504].includes(response.status)) { - if (attempt < 2) { await delay(attempt); continue; } - throw new DemoPreflightError('ARM_TRANSIENT_FAILURE', `ARM remained unavailable after three attempts (${response.status}).`); - } - if (response.status === 404 && allowMissing) return null; - let body: unknown; - try { body = await response.json(); } - catch { throw new DemoPreflightError('ARM_INVALID_RESPONSE', `ARM returned non-JSON (${response.status}); raw contents were not logged.`); } - if (!response.ok) { - const failure = z.object({ error: z.object({ code: z.string() }) }).safeParse(body); - if (failure.success && ['RequestDisallowedByPolicy', 'DenyAssignmentAuthorizationFailed'].includes(failure.data.error.code)) { - throw new DemoPreflightError('AZURE_POLICY_DENIED', 'The selected tenant/resource scope explicitly denied this ARM operation by policy or deny assignment; login refresh is not a repair.'); - } - if (response.status === 401) throw new DemoPreflightError('ARM_TOKEN_REJECTED', 'ARM rejected the tenant token; review tenant-scoped authentication, not Graph/cache discovery.'); - if (response.status === 403) throw new DemoPreflightError('ARM_FORBIDDEN', 'ARM access is forbidden at the selected scope; inspect RBAC/deny evidence separately from Graph permissions.'); - throw new DemoPreflightError('ARM_RESOURCE_NOT_ACCESSIBLE', `The explicit ARM target is not accessible (${response.status}); verify tenant, subscription and resource scope.`); - } - return body; - } - throw new DemoPreflightError('ARM_TRANSIENT_FAILURE', 'ARM retry limit reached.'); - }; - const list = async (path: string, version: string, item: z.ZodType): Promise => { - const expected = new URL(path, 'https://management.azure.com'); - expected.searchParams.set('api-version', version); - let next: URL | undefined = expected; - const visited = new Set(); - const values: T[] = []; - while (next) { - if (visited.size >= 10 || visited.has(next.href) || next.origin !== expected.origin || - next.pathname.toLowerCase() !== expected.pathname.toLowerCase() || next.username || next.password || next.hash || - next.searchParams.get('api-version') !== version) { - throw new DemoPreflightError('ARM_INVALID_RESPONSE', 'ARM pagination exceeded its bound, cycled or left the exact HTTPS resource scope; credentials were not forwarded.'); - } - visited.add(next.href); - const page: { value: T[]; nextLink?: string | null | undefined } = parse(z.object({ value: z.array(item), nextLink: z.string().nullable().optional() }), - await arm(next.href, version), 'paginated list'); - values.push(...page.value); - if (!page.nextLink) next = undefined; - else { - try { next = new URL(page.nextLink, next); } - catch { throw new DemoPreflightError('ARM_INVALID_RESPONSE', 'ARM pagination URL is malformed; raw contents were not logged.'); } - if (!next.searchParams.has('api-version')) next.searchParams.set('api-version', version); - } - } - return values; - }; - const subscription = parse(z.object({ subscriptionId: guid, tenantId: guid, state: z.string() }), - await arm(subscriptionScope, '2022-12-01'), 'subscription'); - if (subscription.subscriptionId !== target.subscriptionId || subscription.tenantId !== target.tenantId) { - throw new DemoPreflightError('SUBSCRIPTION_TENANT_MISMATCH', 'Authoritative ARM subscription/tenant does not match the explicit target.'); - } - if (subscription.state !== 'Enabled') throw new DemoPreflightError('SUBSCRIPTION_NOT_ENABLED', 'The explicit ARM subscription is not enabled.'); - const [group, locations] = await Promise.all([ - arm(groupScope, '2024-03-01', true), - list(`${subscriptionScope}/locations`, '2022-12-01', z.object({ name: z.string(), displayName: z.string() })), - ]); - if (group !== null) { - const resource = parse(z.object({ id: z.string(), location: z.string() }), group, 'resource group'); - if (resource.id.toLowerCase() !== groupScope.toLowerCase()) throw new DemoPreflightError('ARM_INVALID_RESPONSE', 'ARM returned a different resource-group scope.'); - } - const location = locations.find(item => item.name === target.location); - if (!location) throw new DemoPreflightError('REGION_UNSUPPORTED', 'Selected region is not available to this subscription.'); - const staticWebAppLocation = locations.find(item => item.name === (target.staticWebAppLocation ?? target.location)); - if (!staticWebAppLocation) { - throw new DemoPreflightError('REGION_UNSUPPORTED', 'Selected Static Web Apps region is not available to this subscription.'); - } - const [permissions, availableProviders] = await Promise.all([ - list(`${group === null ? subscriptionScope : groupScope}/providers/Microsoft.Authorization/permissions`, '2022-04-01', - z.object({ actions: z.array(z.string().max(256)), notActions: z.array(z.string().max(256)) })), - Promise.all(Object.entries(providers).map(async ([namespace, types]) => { - const provider = parse(z.object({ namespace: z.string(), registrationState: z.string(), - resourceTypes: z.array(z.object({ resourceType: z.string(), locations: z.array(z.string()).optional() })) }), - await arm(`${subscriptionScope}/providers/${namespace}`, '2021-04-01'), 'provider'); - if (provider.namespace !== namespace) throw new DemoPreflightError('ARM_INVALID_RESPONSE', 'ARM returned another provider namespace.'); - if (provider.registrationState !== 'Registered') throw new DemoPreflightError('PROVIDER_REGISTRATION_REQUIRED', `Provider ${namespace} needs explicit registration approval; it was not registered automatically.`); - const providerLocation = namespace === 'Microsoft.Web' ? staticWebAppLocation : location; - for (const type of types) { - const resource = provider.resourceTypes.find(item => item.resourceType.toLowerCase() === type.toLowerCase()); - const normalize = (value: string) => value.replace(/[\s-]/g, '').toLowerCase(); - const requiresRegion = namespace !== 'Microsoft.Authorization' && type !== 'staticSites/linkedBackends'; - if (!resource || (requiresRegion && - !resource.locations?.some(value => normalize(value) === normalize(providerLocation.displayName) || - normalize(value) === (namespace === 'Microsoft.Web' ? target.staticWebAppLocation ?? target.location : target.location)))) { - throw new DemoPreflightError('REGION_UNSUPPORTED', `${namespace}/${type} does not advertise the selected region; no alternate region was selected.`); - } - } - return namespace; - })), - ]); - const actions = ['Microsoft.Resources/deployments/write', - ...Object.entries(providers).flatMap(([namespace, types]) => types.map(type => `${namespace}/${type}/write`)), - 'Microsoft.Sql/servers/databases/write', 'Microsoft.Sql/servers/administrators/write', - 'Microsoft.Sql/servers/virtualNetworkRules/write', - 'Microsoft.Sql/servers/firewallRules/write', 'Microsoft.Sql/servers/firewallRules/delete', - 'Microsoft.Network/virtualNetworks/subnets/write', 'Microsoft.Network/virtualNetworks/subnets/join/action', - 'Microsoft.Network/virtualNetworks/subnets/joinViaServiceEndpoint/action', - 'Microsoft.ManagedIdentity/userAssignedIdentities/assign/action', - 'Microsoft.ContainerRegistry/registries/scheduleRun/action', 'Microsoft.ContainerRegistry/registries/runs/read', - 'Microsoft.ContainerRegistry/registries/listCredentials/action', - 'Microsoft.Web/staticSites/listSecrets/action', - ...(group === null ? ['Microsoft.Resources/subscriptions/resourceGroups/write'] : [])]; - const missing = actions.filter(action => !permissions.some(permission => - permission.actions.some(pattern => actionMatches(pattern, action)) && !permission.notActions.some(pattern => actionMatches(pattern, action)))); - if (missing.length) throw new DemoPreflightError('WRITE_AUTHORIZATION_MISSING', `Effective scope permissions do not include: ${missing.join(', ')}. No resource operation was attempted.`); - return { - version: 1 as const, target, securityPosture: target.securityPosture, cache, arm: { status: 'verified' as const }, - resourceGroup: { status: group === null ? 'requires-approved-creation' as const : 'exists' as const }, - providers: availableProviders, authorization: { status: 'eligible-for-what-if' as const }, - policy: { status: 'not-yet-evaluated' as const, - action: 'Approved target-specific what-if and deployment must evaluate policy, deny assignments, quotas and SQL settings; successful read/permission discovery does not prove deployment success.' }, - graph: { status: 'not-required-and-not-invoked' as const }, - costs: { status: 'not-evaluated' as const, - action: 'Run the offline demo-cost review before what-if. Free SQL/compute grants do not remove registry or managed-network charges. Confirm target pricing and actual grant usage separately; this diagnostic grants no spending consent.' }, - }; -} diff --git a/src/browser-config.ts b/src/browser-config.ts index b5efa50..e1c5ea2 100644 --- a/src/browser-config.ts +++ b/src/browser-config.ts @@ -1,54 +1,24 @@ export const authRedirectPath = '/auth/redirect.html'; -export interface BrowserCapabilities { - files: boolean; - functions: boolean; - processing?: boolean; - jobSubmission?: boolean; - jobRetry?: boolean; - automaticProgress?: boolean; - tracing?: boolean; - progressIntervalMs?: number; -} - +/** The signed-in status page calls readinessPath, the application's read-only DAB procedure. */ export type BrowserConfig = - | { mode: 'entra'; tenantId: string; clientId: string; scope: string; localBackend?: boolean; capabilities: BrowserCapabilities } - | { mode: 'local'; capabilities: BrowserCapabilities }; + | { mode: 'entra'; tenantId: string; clientId: string; scope: string; localBackend?: boolean; readinessPath: string } + | { mode: 'local'; readinessPath: string }; export function parseBrowserConfig(value: unknown): BrowserConfig { if (!value || typeof value !== 'object') throw new Error('Invalid browser configuration'); - const legacyEntra = !('mode' in value); - const capabilities = 'capabilities' in value ? value.capabilities - : legacyEntra ? { files: true, functions: true } : undefined; - if (!capabilities || typeof capabilities !== 'object' || !('files' in capabilities) || - typeof capabilities.files !== 'boolean' || !('functions' in capabilities) || typeof capabilities.functions !== 'boolean') { - throw new Error('Invalid browser service capabilities'); - } - const services = { files: capabilities.files, functions: capabilities.functions }; - if ('processing' in capabilities) { - if (typeof capabilities.processing !== 'boolean') throw new Error('Invalid browser processing capability'); - Object.assign(services, { processing: capabilities.processing }); - } - const optional: Record = { ...capabilities }; - for (const key of ['jobSubmission', 'jobRetry', 'automaticProgress', 'tracing'] as const) { - if (key in optional) { - if (typeof optional[key] !== 'boolean') throw new Error(`Invalid browser ${key} capability`); - Object.assign(services, { [key]: optional[key] }); - } - } - if ('progressIntervalMs' in capabilities) { - if (typeof capabilities.progressIntervalMs !== 'number' || !Number.isInteger(capabilities.progressIntervalMs) || - capabilities.progressIntervalMs < 1000 || capabilities.progressIntervalMs > 30000) throw new Error('Invalid progress interval'); - Object.assign(services, { progressIntervalMs: capabilities.progressIntervalMs }); + const readinessPath = 'readinessPath' in value ? value.readinessPath : undefined; + if (typeof readinessPath !== 'string' || !/^\/api\/[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(readinessPath)) { + throw new Error('Invalid browser readiness path'); } - if ('mode' in value && value.mode === 'local') return { mode: 'local', capabilities: services }; - if ((legacyEntra || ('mode' in value && value.mode === 'entra')) && 'tenantId' in value && typeof value.tenantId === 'string' && value.tenantId && + if ('mode' in value && value.mode === 'local') return { mode: 'local', readinessPath }; + if ('mode' in value && value.mode === 'entra' && 'tenantId' in value && typeof value.tenantId === 'string' && value.tenantId && 'clientId' in value && typeof value.clientId === 'string' && value.clientId && 'scope' in value && typeof value.scope === 'string' && value.scope) { const localBackend = 'localBackend' in value ? value.localBackend : undefined; if ('localBackend' in value && typeof localBackend !== 'boolean') throw new Error('Invalid local backend configuration'); return { mode: 'entra', tenantId: value.tenantId, clientId: value.clientId, scope: value.scope, - ...(typeof localBackend === 'boolean' ? { localBackend } : {}), capabilities: services }; + ...(typeof localBackend === 'boolean' ? { localBackend } : {}), readinessPath }; } throw new Error('Invalid browser authentication configuration'); -} +} \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts index b6d180c..a0f0e9e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,30 +1,18 @@ -import { mkdir, writeFile, readFile } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { z } from 'zod'; import { run } from './process.js'; -import { smoke } from './smoke.js'; -import { deployStatic, setSecret, updateImage } from './maintenance.js'; import { buildArtifacts } from './artifacts.js'; -import { preflightDemo, DemoPreflightError } from './azure-preflight.js'; -import { reviewDemoCost } from './demo-cost.js'; -import { compareHosting } from './hosting-comparison.js'; -import { - demoPublicationSchema, demoPublicationStatus, planDemoPublication, publishDemo, - readDemoPublicationState, validateDemoPublication, type DemoPublicationState, -} from './demo-publication.js'; -import { createDemoPublicationFile } from './demo-init.js'; -import { reviewRoleBasedCost } from './role-based-cost.js'; import { assignApplicationRole, applicationRegistrationRole } from './role-based-identity.js'; import { roleBasedSmoke } from './role-based-smoke.js'; -import { validateRoleBasedCloudSource } from './role-based-profile.js'; -import { initDeploymentConfig, planDeployAll, unsetClientId, type InitOptions } from './azure-init.js'; +import { roleBasedSchemaFingerprint, validateRoleBasedCloudSource } from './role-based-profile.js'; +import { initDeploymentConfig, planDeployAll, unsetClientId } from './azure-init.js'; import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { compileArchitecture } from './architecture-compiler.js'; import { - configureRedirect, delegatedScopeId, deploy, deploymentConfigHash, functionRoleId, parametersFile, preflight, - publishSchema, readConfig, readState, saveJson, saveState, statePath, withDeploymentLock, provision, - deploymentTemplatePath, type DeploymentState, + configureRedirect, delegatedScopeId, deploy, deploymentConfigHash, grantsHandoffPath, parametersFile, preflight, + publishSchema, readConfig, readSavedState, readState, saveJson, saveState, statePath, withDeploymentLock, provision, + deploymentTemplatePath, rbacParametersFile, rbacTemplatePath, verifyRbacAssignments, writeGrantsHandoff, } from './deployment.js'; const [command, configPath = 'sql-apps.json', argument] = process.argv.slice(2); @@ -40,106 +28,23 @@ function parseFlags(args: string[], allowed: string[]): Record [config.json] [options]\nRequires a current build (after approval, consent-run build). Azure CLI, Bicep, .NET, SqlPackage and the local pinned SWA CLI are used directly.'); - console.log('Architecture recommendation: architecture-plan compiles an offline/read-only recommendation with no Azure writes. It performs no Azure calls and is not deployment approval.'); - console.log('Public-demo diagnostics: demo-preflight . Read-only cache/tenant/ARM/provider/region/permission evidence; no Graph, app registration, login reset or resource writes. Not deployment readiness.'); - console.log('Public-demo starter: demo-init --subscription --resource-group --location --static-web-app-location --application --artifact --owner . Discovery only; creates no Azure resources.'); - console.log('Public-demo costs: demo-cost . Offline free-allowance/fixed-charge review; no Azure login, network requests or resource writes. Exit 2 means cost intent is blocked or acknowledgement is missing; exit 1 means invalid input.'); - console.log('Role-based-data: role-based-cost is offline and profile-specific. Use validate/artifacts/identity/plan/provision/deploy/status/schema with a role-based-data deployment configuration. role-based-assign explicitly assigns its application role; role-based-smoke needs delegated tokens with and without the required role in SQL_APPS_USER_TOKEN / SQL_APPS_SECOND_USER_TOKEN. All cloud writes need operator approval; local simulations never deploy.'); - console.log('Shortest path: az login, then init --profile

--location --resource-group --registry .azurecr.io (read-only), then deploy-all to review pending paid steps and deploy-all --approve after explicit approval.'); - return; - } - if (command === 'architecture-plan') { - if (process.argv.length !== 4) { - throw new Error('Usage: architecture-plan '); - } - const input: unknown = JSON.parse(await readFile(resolve(configPath), 'utf8')); - console.log(JSON.stringify(compileArchitecture(input), null, 2)); - return; - } - if (command === 'hosting-compare') { - if (process.argv.length !== 4) throw new Error('Usage: hosting-compare '); - console.log(JSON.stringify(compareHosting(JSON.parse(await readFile(resolve(configPath), 'utf8'))), null, 2)); - return; - } - if (command === 'demo-init') { - if (process.argv.length < 4 || !configPath.endsWith('.json')) { - throw new Error('Usage: demo-init --subscription --resource-group --location --static-web-app-location --application --artifact --owner '); - } - const flags = parseFlags(process.argv.slice(4), [ - 'subscription', 'resource-group', 'location', 'static-web-app-location', 'application', 'artifact', 'owner', - ]); - const outputPath = resolve(configPath); - if (existsSync(outputPath)) throw new Error(`${configPath} already exists; demo-init never overwrites a configuration.`); - for (const key of [ - 'subscription', 'resource-group', 'location', 'static-web-app-location', 'application', 'artifact', 'owner', - ]) { - if (!flags[key]) throw new Error(`demo-init needs --${key}.`); - } - await createDemoPublicationFile(outputPath, { - subscriptionId: flags.subscription!, - resourceGroup: flags['resource-group']!, - location: flags.location!, - staticWebAppLocation: flags['static-web-app-location']!, - selectedApplication: flags.application!, - selectedArtifact: flags.artifact!, - owner: flags.owner!, - }, run); - console.log(`Wrote ${outputPath} for Starter public-demo publication. Nothing was created in Azure.`); - console.log(`Next (read-only): demo-preflight , then demo-publish ${outputPath}.`); - return; - } - if (command === 'demo-publish' || command === 'demo-status') { - if (process.argv.length < 4) throw new Error(`Usage: ${command} ${command === 'demo-publish' ? ' [--approve ]' : ''}`); - const flags = command === 'demo-publish' ? parseFlags(process.argv.slice(4), ['approve']) : {}; - if (command === 'demo-status' && process.argv.length !== 4) throw new Error('demo-status accepts exactly one publication configuration'); - const publication = demoPublicationSchema.parse(JSON.parse(await readFile(resolve(configPath), 'utf8'))); - await validateDemoPublication(publication); - if (command === 'demo-status') { - console.log(JSON.stringify(await demoPublicationStatus(publication, run), null, 2)); - return; - } - let state: DemoPublicationState | undefined; - try { state = await readDemoPublicationState(publication); } - catch (error) { - if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; - } - const reviewed = planDemoPublication(publication, state); - if (flags.approve !== reviewed.approval) { - if (flags.approve) { - throw new Error('The configuration, selected artifact, or pending publication work changed; review demo-publish again'); - } - console.log(reviewed.warning); - console.log(`Pending paid/write stages: ${reviewed.pending.length ? reviewed.pending.join(' -> ') : 'none'}`); - for (const item of reviewed.review) console.log(item); - console.log(`Excluded: ${reviewed.excluded.join('; ')}.`); - console.log(`Approval ID: ${reviewed.approval}`); - if (reviewed.pending.length) console.log(`After explicit approval: demo-publish ${configPath} --approve ${reviewed.approval}`); - return; - } - if (!reviewed.pending.length) { - console.log('Public-demo publication is already at the saved ready stage; run demo-status for bounded live checks.'); - return; - } - try { - const completed = await publishDemo(publication, run, fetch, reviewed.approval); - console.log(`Public-demo publication completed at ${completed.runtime?.publicUrl ?? 'the reviewed Static Web App URL'}.`); - } catch (error) { - console.error(`Publication stopped safely. Resume only after review: demo-publish ${configPath}`); - throw error; - } + console.log('Usage: npm run azure -- [config.json] [options]\nRequires a current build (after approval, consent-run build). Azure CLI, Bicep, .NET and SqlPackage are used directly.'); + console.log('New deployments: use maintained Azure Skills for onboarding, preparation, validation, deployment, cost and diagnostics. These commands deploy private Azure SQL, Data API builder with managed-identity grants and live SQL Apps acceptance, and remain supported for existing SQL Apps deployments.'); + console.log('Updates: change SQL, DAB or configuration, run artifacts to rebuild images, then deploy; deploy rolls every stage forward when anything changed. grants writes dist/sql/role-based-grants.sql, the exact DAB procedure grants, for a maintained deployment route to apply.'); + console.log('role-based-assign explicitly assigns the application role; role-based-smoke needs delegated tokens with and without the required role in SQL_APPS_USER_TOKEN / SQL_APPS_SECOND_USER_TOKEN. All cloud writes need operator approval; local simulations never deploy.'); + console.log('Shortest path: stay local for POCs. For cloud validation: az login, then init --location --resource-group --registry .azurecr.io (read-only). Review costs with the Azure Cost tooling before approving paid steps.'); return; } if (command === 'init') { - const flags = parseFlags(process.argv.slice(4), ['profile', 'location', 'resource-group', 'registry', 'environment', 'name']); - if (process.argv.length < 4 || !configPath.endsWith('.json')) throw new Error('Usage: init --profile --location --resource-group --registry .azurecr.io [--environment dev|test|prod] [--name ]'); + const flags = parseFlags(process.argv.slice(4), ['location', 'resource-group', 'registry', 'environment', 'name']); + if (process.argv.length < 4 || !configPath.endsWith('.json')) throw new Error('Usage: init --location --resource-group --registry .azurecr.io [--environment dev|test|prod] [--name ].'); if (existsSync(resolve(configPath))) throw new Error(`${configPath} already exists; init never overwrites a configuration.`); - for (const key of ['profile', 'location', 'resource-group', 'registry']) if (!flags[key]) throw new Error(`init needs --${key}; tenant, subscription and SQL administrator come from az login.`); + for (const key of ['location', 'resource-group', 'registry']) if (!flags[key]) throw new Error(`init needs --${key}; tenant, subscription and SQL administrator come from az login.`); const config = await initDeploymentConfig({ - profile: flags.profile as InitOptions['profile'], location: flags.location!, resourceGroup: flags['resource-group']!, - registryServer: flags.registry!, environment: flags.environment as InitOptions['environment'], name: flags.name, + location: flags.location!, resourceGroup: flags['resource-group']!, registryServer: flags.registry!, + environment: flags.environment as 'dev' | 'test' | 'prod' | undefined, name: flags.name, }, run); - if (config.profile === 'role-based-data') await validateRoleBasedCloudSource({ profile: 'role-based-data', requiredRole: config.requiredRole!, readinessPath: config.readinessPath! }); + await validateRoleBasedCloudSource(config); await saveJson(resolve(configPath), config); console.log(`Wrote ${configPath} for subscription ${config.subscriptionId} (tenant ${config.tenantId}); ${config.sqlAdminName} becomes the SQL administrator. Nothing was created in Azure.`); console.log(`Next (read-only): plan ${configPath} after identity, or deploy-all ${configPath} to review the paid steps.`); @@ -149,20 +54,17 @@ async function main() { const flags = parseFlags(process.argv.slice(4), ['approve']); const load = async () => { const current = await readConfig(resolve(configPath)); - let state: DeploymentState | undefined; - try { state = await readState(current); } - catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; } - if (state && state.configHash !== await deploymentConfigHash(current)) { - throw new Error('Deployment configuration or SQL/DAB source changed; review the saved deployment before resuming deploy-all.'); - } - return planDeployAll(current, state?.stage); + const state = await readSavedState(current); + // A changed configuration or SQL/DAB source keeps the saved deployment but makes deploy pending again. + const stage = state && state.configHash !== await deploymentConfigHash(current) ? 'infrastructure' : state?.stage; + return planDeployAll(current, stage, await roleBasedSchemaFingerprint(resolve('.'))); }; const reviewed = await load(); if (flags.approve !== reviewed.approval) { if (flags.approve) throw new Error('The configuration or remaining work changed since review; run deploy-all again and review the new list.'); console.log(`Pending paid Azure steps for ${configPath}: ${reviewed.pending.join(' -> ')}`); console.log(`Not included: ${reviewed.excluded.join('; ')}.`); - console.log('Review cost first with demo-cost or role-based-cost, and plan (what-if) once identity exists.'); + console.log('Review cost first with the Azure Cost tooling, and plan (what-if) once identity exists.'); console.log(`After explicit approval: deploy-all ${configPath} --approve ${reviewed.approval}`); return; } @@ -172,31 +74,19 @@ async function main() { const code = await new Promise((done, reject) => spawn(process.execPath, [process.argv[1]!, step, configPath], { stdio: 'inherit' }) .on('error', reject).on('exit', done)); if (code !== 0) throw new Error(`${step} failed; fix the cause, then run deploy-all ${configPath} again to resume from the remaining steps.`); + if (step === 'provision') { + console.log(`Infrastructure is ready. An administrator must review rbac-plan ${configPath} and run rbac-deploy ${configPath}; then rerun deploy-all to continue with live-verified RBAC.`); + return; + } } console.log('deploy-all finished. Assign the application role (role-based-assign) and run smoke tests as separate decisions.'); return; } - if (command === 'demo-cost' || command === 'role-based-cost') { - if (process.argv.length !== 4) throw new Error('Provide an explicit matching-profile cost configuration; no billing choice or spending consent is inferred. Copy and review azure-demo-cost.example.json (foundation) or azure-role-based-cost.example.json (role-based-data).'); - const input: unknown = JSON.parse(await readFile(resolve(configPath), 'utf8')); - const report = command === 'role-based-cost' ? reviewRoleBasedCost(input) : reviewDemoCost(input); - console.log(JSON.stringify(report, null, 2)); - if (!report.review.canProceedToWhatIf) process.exitCode = 2; - return; - } - if (command === 'demo-preflight') { - if (process.argv.length !== 4) throw new Error('Provide an explicit public-demo target configuration; no subscription/tenant defaults are inferred.'); - const target: unknown = JSON.parse(await readFile(resolve(configPath), 'utf8')); - console.log(JSON.stringify(await preflightDemo(target, run), null, 2)); - return; - } - if (!['validate', 'artifacts', 'identity', 'plan', 'provision', 'deploy', 'status', 'schema', 'static', 'redirect', 'smoke', 'gateway', 'functions', 'secret-set', 'role-based-assign', 'role-based-smoke'].includes(command)) { + if (!['validate', 'artifacts', 'identity', 'plan', 'provision', 'rbac-plan', 'rbac-deploy', 'deploy', 'status', 'schema', 'grants', 'redirect', 'role-based-assign', 'role-based-smoke'].includes(command)) { throw new Error(`Unknown command: ${command}`); } const config = await readConfig(resolve(configPath)); - if (config.profile === 'role-based-data') await validateRoleBasedCloudSource({ - profile: 'role-based-data', requiredRole: config.requiredRole!, readinessPath: config.readinessPath!, - }); + await validateRoleBasedCloudSource(config); if (command === 'role-based-assign') { if (!argument || process.argv.length !== 5) throw new Error('Provide exactly one authorized user/group object ID'); await assignApplicationRole(config, argument, run); @@ -204,6 +94,11 @@ async function main() { return; } if (command === 'validate') { console.log('Configuration valid'); return; } + if (command === 'grants') { + await writeGrantsHandoff(config); + console.log(`Wrote ${grantsHandoffPath()}: the DAB procedure grants SQL Apps applies, for a maintained deployment route to apply after it creates [sql_apps_dab].`); + return; + } if (config.apiClientId === unsetClientId && command !== 'identity') throw new Error('apiClientId is not set yet; run identity (or deploy-all) first.'); if (command === 'artifacts') { await withDeploymentLock(statePath(config), async () => { @@ -213,7 +108,7 @@ async function main() { } await saveJson(resolve(configPath), pinned); }); - console.log(`${config.profile === 'role-based-data' ? 'Two' : 'Three'} images published to ACR; configuration atomically updated to immutable digests.`); + console.log('Two images published to ACR; configuration atomically updated to immutable digests and the source they were built from. Run deploy to roll them out.'); return; } if (command === 'identity') { @@ -232,10 +127,7 @@ async function main() { userConsentDisplayName: 'Access SQL Apps', userConsentDescription: 'Access your application data.', }] }, spa: { redirectUris: [] }, - appRoles: config.profile === 'role-based-data' ? [applicationRegistrationRole(config)] : [{ - id: functionRoleId, value: 'Function.Invoke', displayName: 'Invoke functions', - description: 'Allow the gateway to invoke application functions.', allowedMemberTypes: ['Application'], isEnabled: true, - }], + appRoles: [applicationRegistrationRole(config)], })); await run('az', ['rest', '--method', 'PATCH', '--url', `https://graph.microsoft.com/v1.0/applications/${registration.id}`, '--body', `@${path}`, '-o', 'none']); await run('az', ['ad', 'sp', 'create', '--id', registration.appId, '-o', 'none']); @@ -252,6 +144,22 @@ async function main() { '--parameters', `@${await parametersFile(config, true)}`])); return; } + if (command === 'rbac-plan' || command === 'rbac-deploy') { + // readState already proves the saved deployment belongs to this environment and checkout. + const state = await readState(config); + const parameters = `@${await rbacParametersFile(config, state.outputs)}`; + const common = ['--subscription', config.subscriptionId, '--resource-group', config.resourceGroup, + '--template-file', rbacTemplatePath(), '--parameters', parameters]; + if (command === 'rbac-plan') { + console.log(await run('az', ['deployment', 'group', 'what-if', ...common])); + return; + } + await run('az', ['deployment', 'group', 'create', '--name', `${config.name}-${config.environment}-rbac`, + ...common, '-o', 'none']); + await verifyRbacAssignments(config, state.outputs, run); + console.log('Privileged RBAC deployment completed and every required assignment was verified from live Azure state. The ordinary deployer can now run deploy.'); + return; + } if (command === 'deploy') { await withDeploymentLock(statePath(config), async () => { const state = await deploy(config, run); @@ -263,7 +171,7 @@ async function main() { await withDeploymentLock(statePath(config), async () => { const state = await provision(config, run); await saveState(statePath(config), state); - console.log(`Infrastructure available (saved stage: ${state.stage}). Configure private runner connectivity to ${state.outputs.networkId}, then run deploy. Saved stages are historical, not live acceptance.`); + console.log(`Infrastructure available (saved stage: ${state.stage}). An administrator with roleAssignments/write must review rbac-plan ${configPath} and run rbac-deploy ${configPath}. After live RBAC verification, configure private runner connectivity to ${state.outputs.networkId}, then run deploy. Saved stages are historical, not live acceptance.`); }); return; } @@ -276,41 +184,6 @@ async function main() { console.log('Live authorized procedure succeeded; requests from valid users without the required role and anonymous callers were denied, including forged role headers. Browser save/reload remains separate acceptance.'); return; } - if (command === 'smoke' && config.profile === 'role-based-data') { - throw new Error('Use role-based-smoke for role-based-data, not the foundation file/job smoke suite'); - } - if (command === 'static') { - await preflight(config, run, false); - await withDeploymentLock(statePath(config), async () => { - const pinned = await deployStatic(config, state, run); - if (JSON.stringify(await readConfig(resolve(configPath))) !== JSON.stringify(config)) { - throw new Error('Configuration changed during static deployment; runtime updated but configuration was not overwritten'); - } - await saveJson(resolve(configPath), pinned); - }); - console.log('Static assets deployed on the existing runtime digest; verify status and browser behavior.'); - return; - } - if (command === 'gateway' || command === 'functions') { - await preflight(config, run); - await withDeploymentLock(statePath(config), () => updateImage(command, config, state, run)); - console.log(`${command} image updated; run status and authenticated smoke to verify.`); - return; - } - if (command === 'secret-set') { - if (!argument || !process.env.SQL_APPS_SECRET_VALUE) throw new Error('Provide a secret name and set SQL_APPS_SECRET_VALUE in the trusted deployment process.'); - await withDeploymentLock(statePath(config), () => setSecret(argument, process.env.SQL_APPS_SECRET_VALUE!, config, state, run)); - console.log('Key Vault secret updated without persisting its value locally.'); - return; - } - if (command === 'smoke') { - const first = process.env.SQL_APPS_USER_TOKEN; - const second = process.env.SQL_APPS_SECOND_USER_TOKEN; - if (!first || !second) throw new Error('Set SQL_APPS_USER_TOKEN and SQL_APPS_SECOND_USER_TOKEN for two users; tokens are not persisted.'); - await smoke(state.outputs.gatewayUrl, first, second, fetch, `https://${state.outputs.functionsName}.azurewebsites.net`); - console.log('Authenticated two-user REST and GraphQL reads, files, function invocation, anonymous rejection, and fixture cleanup passed.'); - return; - } if (command === 'status') { console.log(await run('az', ['containerapp', 'show', '--subscription', config.subscriptionId, '--resource-group', config.resourceGroup, '--name', state.outputs.gatewayName, @@ -334,8 +207,7 @@ async function main() { throw new Error(`Unknown command: ${command}`); } main().catch(error => { - console.error(error instanceof DemoPreflightError ? `${error.stage}: ${error.code}: ${error.message}` : - error instanceof z.ZodError ? `Configuration is invalid:\n${z.prettifyError(error)}` : + console.error(error instanceof z.ZodError ? `Configuration is invalid:\n${z.prettifyError(error)}` : error instanceof Error ? error.message : 'Deployment command failed'); process.exitCode = 1; }); diff --git a/src/client.ts b/src/client.ts index 582ca36..9eb0284 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,3 @@ -import { fileJobSchema, type FileJob } from './file-jobs.js'; -import { z } from 'zod'; -import { traceRecordSchema, type TraceRecord } from './trace-contract.js'; export interface ClientOptions { baseUrl: string; @@ -8,11 +5,6 @@ export interface ClientOptions { fetch?: typeof fetch; } -export interface ClientFilePage { - files: { name: string; size: number }[]; - continuationToken?: string; -} - export class ApiError extends Error { constructor(public readonly status: number) { super(`Application request failed (${status})`); @@ -42,51 +34,12 @@ export class SqlAppsClient { if (!response.ok) throw new ApiError(response.status); return response; } - async upload(name: string, bytes: Blob | Uint8Array): Promise { - await this.request(`/storage/${encodeURIComponent(name)}`, { - method: 'PUT', headers: { 'content-type': 'application/octet-stream' }, - body: bytes instanceof Blob ? bytes : new Uint8Array(bytes).buffer, - }); - } - async download(name: string): Promise { - return (await this.request(`/storage/${encodeURIComponent(name)}`)).arrayBuffer(); - } - async deleteFile(name: string): Promise { - await this.request(`/storage/${encodeURIComponent(name)}`, { method: 'DELETE' }); - } - async listFiles(continuationToken?: string): Promise { - const path = continuationToken === undefined ? '/storage' : - `/storage?${new URLSearchParams({ continuationToken })}`; - const page = z.object({ - files: z.array(z.object({ name: z.string(), size: z.number().nonnegative() })), - continuationToken: z.string().min(1).max(2048).optional(), - }).parse(await (await this.request(path)).json()); - return { - files: page.files, - ...(page.continuationToken ? { continuationToken: page.continuationToken } : {}), - }; - } - async listJobs(signal?: AbortSignal): Promise { - return z.object({ jobs: z.array(fileJobSchema) }).parse(await (await this.request('/jobs', signal ? { signal } : {})).json()).jobs; - } - async processFile(name: string): Promise { - const response = await this.request('/jobs', { - method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }), - }); - return z.object({ id: z.string().uuid() }).parse(await response.json()).id; - } - async deleteJob(id: string): Promise { - await this.request(`/jobs/${encodeURIComponent(id)}`, { method: 'DELETE' }); - } - async retryJob(id: string): Promise { - return z.object({ id: z.string().uuid() }).parse(await (await this.request(`/jobs/${encodeURIComponent(id)}/retry`, { method: 'POST' })).json()).id; - } - async listTraces(): Promise { - return z.object({ traces: z.array(traceRecordSchema) }).parse(await (await this.request('/diagnostics/traces')).json()).traces; - } - async echo(input: unknown): Promise { - return (await this.request('/functions/echo', { - method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input), - })).json(); + /** Calls the application's read-only readiness procedure through the gateway and returns DAB's rows. */ + async readiness(path: string): Promise { + const body: unknown = await (await this.request(path)).json(); + if (!body || typeof body !== 'object' || !('value' in body) || !Array.isArray(body.value)) { + throw new Error('Invalid readiness procedure response'); + } + return body.value; } } diff --git a/src/config.ts b/src/config.ts index ef1e441..ab87a72 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,9 +20,9 @@ const image = z.string().regex( ).refine(value => !value.endsWith(':latest'), 'Use an explicit version, not latest'); export const deploymentSchema = z.strictObject({ - profile: z.enum(['foundation', 'role-based-data']).optional(), - requiredRole: requiredRoleSchema.optional(), - readinessPath: readinessPathSchema.optional(), + profile: z.literal('role-based-data'), + requiredRole: requiredRoleSchema, + readinessPath: readinessPathSchema, subscriptionId: uuid, tenantId: uuid, apiClientId: uuid, @@ -34,18 +34,14 @@ export const deploymentSchema = z.strictObject({ name: z.string().regex(/^[a-z][a-z0-9-]{2,19}$/), gatewayImage: image, dabImage: image, - functionsImage: z.union([image, z.literal('')]).default(''), + // Configurations written by 0.1.x init carry an empty functionsImage; accept and ignore it. + functionsImage: z.literal('').optional(), + // SQL/DAB source fingerprint the pinned images were built from; artifacts writes it. + imageSource: z.string().regex(/^[a-f0-9]{64}$/).optional(), registryServer: z.string().regex(/^[a-z0-9]+\.azurecr\.io$/), }).superRefine((config, context) => { - if (config.profile === 'role-based-data') { - if (!config.requiredRole || !config.readinessPath || config.functionsImage) { - context.addIssue({ code: 'custom', message: 'Role-based-data requires requiredRole/readinessPath and excludes functionsImage' }); - } - } else if (!config.functionsImage || config.requiredRole || config.readinessPath) { - context.addIssue({ code: 'custom', message: 'Foundation requires functionsImage and does not select role-based-data settings' }); - } - for (const field of ['gatewayImage', 'dabImage', 'functionsImage'] as const) { - if (config[field] && !config[field].startsWith(`${config.registryServer}/`)) { + for (const field of ['gatewayImage', 'dabImage'] as const) { + if (!config[field].startsWith(`${config.registryServer}/`)) { context.addIssue({ code: 'custom', path: [field], message: 'Image must belong to the configured ACR' }); } } @@ -54,38 +50,23 @@ export const deploymentSchema = z.strictObject({ export type DeploymentConfig = z.infer; export const runtimeSchema = z.strictObject({ - profile: z.enum(['foundation', 'role-based-data']).optional(), - requiredRole: requiredRoleSchema.optional(), - readinessPath: readinessPathSchema.optional(), + requiredRole: requiredRoleSchema, + readinessPath: readinessPathSchema, tenantId: uuid, apiClientId: uuid, dabUrl: httpsOrigin, - blobAccountUrl: httpsOrigin, - blobContainer: z.string().regex(/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/), - functionsUrl: httpsOrigin, publicDirectory: z.string().min(1), -}).superRefine((config, context) => { - if (config.profile === 'role-based-data' && (!config.requiredRole || !config.readinessPath)) { - context.addIssue({ code: 'custom', message: 'Role-based-data runtime requires requiredRole and readinessPath' }); - } - if (config.profile !== 'role-based-data' && (config.requiredRole || config.readinessPath)) { - context.addIssue({ code: 'custom', message: 'Role-based settings require the role-based-data profile' }); - } }); export type RuntimeConfig = z.infer; export function loadRuntimeConfig(env: NodeJS.ProcessEnv): RuntimeConfig { return runtimeSchema.parse({ - profile: env.SQL_APPS_PROFILE, requiredRole: env.SQL_APPS_REQUIRED_ROLE, readinessPath: env.SQL_APPS_READINESS_PATH, tenantId: env.AZURE_TENANT_ID, apiClientId: env.API_CLIENT_ID, dabUrl: env.DAB_URL, - blobAccountUrl: env.BLOB_ACCOUNT_URL ?? (env.SQL_APPS_PROFILE === 'role-based-data' ? 'https://unconfigured.invalid' : undefined), - blobContainer: env.BLOB_CONTAINER ?? (env.SQL_APPS_PROFILE === 'role-based-data' ? 'files' : undefined), - functionsUrl: env.FUNCTIONS_URL ?? (env.SQL_APPS_PROFILE === 'role-based-data' ? 'https://unconfigured.invalid' : undefined), publicDirectory: env.PUBLIC_DIRECTORY ?? 'public', }); } diff --git a/src/demo-cost.ts b/src/demo-cost.ts deleted file mode 100644 index fe24b26..0000000 --- a/src/demo-cost.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { z } from 'zod'; - -const sku = z.strictObject({ - name: z.string().regex(/^[A-Za-z0-9_]{1,64}$/), - tier: z.string().regex(/^[A-Za-z]{1,32}$/), - capacity: z.number().finite().positive(), - family: z.string().regex(/^[A-Za-z0-9]{1,32}$/).optional(), -}); -const costSchema = z.strictObject({ - version: z.literal(1), profile: z.literal('public-demo'), - hosting: z.enum(['container-apps', 'static-web-apps']).default('container-apps'), - intent: z.enum(['zero-azure-spend', 'review-paid-costs']), - acknowledgeFixedCharges: z.boolean(), - sql: z.discriminatedUnion('mode', [ - z.strictObject({ mode: z.literal('free-paused') }), - z.strictObject({ - mode: z.literal('paid-reviewed'), sku, - maxSizeBytes: z.number().int().min(104857600).max(Number.MAX_SAFE_INTEGER), - backupRedundancy: z.enum(['Local', 'Zone', 'Geo', 'GeoZone']), - }), - ]), -}).superRefine((value, context) => { - if (value.intent === 'zero-azure-spend' && (value.sql.mode !== 'free-paused' || value.acknowledgeFixedCharges)) { - context.addIssue({ code: 'custom', message: 'A zero-spend intent requires free-paused SQL and cannot acknowledge paid fixed charges.' }); - } -}); - -export function reviewDemoCost(input: unknown) { - const config = costSchema.parse(input); - const free = config.sql.mode === 'free-paused'; - const status = config.intent === 'zero-azure-spend' ? 'blocked-by-fixed-charges' : - !config.acknowledgeFixedCharges ? 'fixed-charge-acknowledgement-required' : 'ready-for-target-cost-review'; - const containers = [ - { name: 'gateway', vCpu: 0.25, memoryGiB: 0.5 }, - { name: 'data', vCpu: 0.5, memoryGiB: 1 }, - ]; - const vCpu = containers.reduce((total, container) => total + container.vCpu, 0); - const memoryGiB = containers.reduce((total, container) => total + container.memoryGiB, 0); - const sqlParameters = config.sql.mode === 'free-paused' ? { - sqlBillingMode: config.sql.mode, - sqlSku: { name: 'GP_S_Gen5_2', tier: 'GeneralPurpose', family: 'Gen5', capacity: 2 }, - sqlMaxSizeBytes: 32 * 1024 ** 3, sqlBackupRedundancy: 'Local', - } : { - sqlBillingMode: config.sql.mode, sqlSku: config.sql.sku, - sqlMaxSizeBytes: config.sql.maxSizeBytes, sqlBackupRedundancy: config.sql.backupRedundancy, - }; - return { - version: 1, profile: config.profile, hosting: config.hosting, intent: config.intent, - review: { - status, canProceedToWhatIf: status === 'ready-for-target-cost-review', - nextAction: config.intent === 'zero-azure-spend' ? - 'Stay local or separately review an alternative secure hosting/image distribution design. The current template has fixed charges; do not provision it as free or weaken SQL access.' : - !config.acknowledgeFixedCharges ? - 'Review registry and managed-network pricing before explicitly acknowledging their fixed charges. No deployment approval is inferred.' : - 'Review region/currency/account pricing, existing grant consumption and target eligibility before separately approved what-if. Pricing and deployment readiness are not verified.', - }, - deployment: { authorized: false, ready: false, scope: 'Offline review for new minimal demo databases only; not a deployment command or existing-database conversion.' }, - estimatedMonthlyCost: null, - pricingStatus: 'not-quoted-region-currency-and-account-review-required', - fixedCharges: [ - { resource: 'ACR Basic', reason: 'Private registry has a recurring tier charge, independent of app traffic.', - pricing: 'https://azure.microsoft.com/pricing/details/container-registry/' }, - { resource: 'Custom-network Container Apps managed infrastructure', - reason: 'Managed public IP/load-balancer infrastructure can incur charges even when app replicas scale to zero.', - pricing: 'https://learn.microsoft.com/azure/container-apps/custom-virtual-networks#managed-resources' }, - ...(config.hosting === 'static-web-apps' ? [{ - resource: 'Azure Static Web Apps Standard', - reason: 'A linked Container Apps backend requires Standard; the Container App, ACR, SQL, and networking remain.', - pricing: 'https://azure.microsoft.com/pricing/details/app-service/static/', - }] : []), - ], - sql: { - parameters: sqlParameters, - exhaustionBehavior: free ? 'AutoPause' : 'not-a-free-tier', - allowance: free ? { computeVCoreSeconds: 100000, dataGiB: 32, backupGiB: 32, databasesPerSubscription: 10 } : null, - actualUsage: 'not-queried', - eligibility: 'not-verified-region-subscription-and-free-slot-check-required', - warning: free ? - 'AutoPause makes SQL unavailable until next calendar month when the free limit is reached. Idle auto-pause/cold resume can affect responsiveness. Open connections can prevent idle auto-pause. Paid continuation cannot revert to monthly-limit AutoPause; never enable it automatically or fall back to paid SQL.' : - 'Ordinary SQL billing requires reviewed sizing. This is not free-tier paid continuation or permission to convert an existing database; review existing state and irreversible changes separately.', - }, - compute: { - plan: 'Consumption', minReplicas: 0, maxReplicas: 1, - containers, resources: { vCpu, memoryGiB }, - allowance: { vCpuSeconds: 180000, memoryGiBSeconds: 360000, requests: 2000000, scope: 'subscription-per-calendar-month' }, - theoreticalActiveReplicaHours: Math.min(180000 / vCpu, 360000 / memoryGiB) / 3600, - actualUsage: 'not-queried', - warning: 'Illustrative active allocation time with all grants unused, not visitor-hours or a spending cap. Both gateway and DAB count. Startup, idle gaps, bots and other subscription apps affect consumption; overages are billable.', - }, - monitoring: { - status: 'workspace-provisioned-alerts-not-configured', - logs: 'The demo environment sends Container Apps console logs to its own Log Analytics workspace, capped at 0.1 GB per day and 30 day retention so it stays inside the included free allowance. Ingestion above the cap is dropped, not billed; raising the cap or retention is billable.', - sql: 'Check Free monthly vCore amount / Free amount remaining in the SQL portal; create a reviewed alert before exhaustion.', - compute: 'Review subscription-wide Container Apps usage and remaining grants, not just this app or request count.', - costs: 'Review actual and forecast costs across the app resource group and its managed infrastructure resource group. Keep cleanup ownership explicit.', - budgetWarning: 'Budgets are delayed alerts, not a hard spending cap; they do not stop resources or consumption.', - scaleTriggers: ['Allowance exhaustion', 'Cold starts are unacceptable', 'People rely on continuous availability', - 'Measured capacity/latency pressure', 'Real-data access, retention or recovery requirements'], - }, - references: { - checkedOn: '2026-10-09', - sql: 'https://learn.microsoft.com/azure/azure-sql/database/free-offer', - compute: 'https://learn.microsoft.com/azure/container-apps/billing', - logs: 'https://azure.microsoft.com/pricing/details/monitor/', - budgets: 'https://learn.microsoft.com/azure/cost-management-billing/costs/tutorial-acm-create-budgets', - note: 'Free tier means recurring monthly allowances, not time-bound trial credits or unlimited usage. Documented allowances are not subscription eligibility, live usage or a price quote. Recheck before deployment.', - }, - }; -} diff --git a/src/demo-init.ts b/src/demo-init.ts deleted file mode 100644 index 2fce5e9..0000000 --- a/src/demo-init.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { isIP } from 'node:net'; -import { resolve } from 'node:path'; -import { z } from 'zod'; -import { demoPublicationSchema, type DemoPublicationConfig } from './demo-publication.js'; -import { createJsonFile } from './deployment.js'; -import type { Run } from './process.js'; - -const guid = z.string().uuid().transform(value => value.toLowerCase()); - -export const demoInitOptionsSchema = z.strictObject({ - subscriptionId: guid, - resourceGroup: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.()-]{0,89}$/), - location: z.string().regex(/^[a-z0-9]+$/), - staticWebAppLocation: z.string().regex(/^[a-z0-9]+$/), - selectedApplication: z.string().regex(/^[a-z][a-z0-9-]{0,31}$/), - selectedArtifact: z.string().min(1), - owner: z.string().trim().min(1).max(256), -}); -export type DemoInitOptions = z.input; - -const accountSchema = z.strictObject({ id: guid, tenantId: guid }); -const tokenSchema = z.object({ accessToken: z.string().min(1) }); -const claimsSchema = z.object({ - oid: guid, - name: z.string().trim().min(1).optional(), - preferred_username: z.string().trim().min(1).optional(), -}).refine(claims => claims.name !== undefined || claims.preferred_username !== undefined, { - message: 'ARM token identity claims require name or preferred_username', -}); -const ipSchema = z.strictObject({ ip: z.string() }); - -function decodeClaims(token: string) { - const parts = token.split('.'); - if (parts.length !== 3) throw new Error('ARM access token must be a JWT with exactly 3 parts'); - let body: unknown; - try { - body = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString('utf8')); - } catch { - throw new Error('ARM access token claims are not valid base64url JSON'); - } - const claims = claimsSchema.safeParse(body); - if (!claims.success) throw new Error('ARM access token identity claims are invalid'); - return claims.data; -} - -async function discoverPublicIpv4(fetcher: typeof fetch): Promise { - const response = await fetcher('https://api.ipify.org?format=json', { - headers: { Accept: 'application/json' }, - redirect: 'error', - signal: AbortSignal.timeout(5000), - }); - if (!response.ok) throw new Error(`Public IPv4 discovery failed with HTTP ${response.status}`); - let body: unknown; - try { - body = JSON.parse(await response.text()); - } catch { - throw new Error('Public IPv4 discovery returned malformed JSON'); - } - const parsed = ipSchema.safeParse(body); - if (!parsed.success) throw new Error('Public IPv4 discovery returned a malformed IPv4 response'); - const octets = parsed.data.ip.split('.').map(Number); - const [first = -1, second = -1] = octets; - const nonPublic = first === 0 || first === 10 || first === 127 || first >= 224 || - (first === 100 && second >= 64 && second <= 127) || - (first === 169 && second === 254) || - (first === 172 && second >= 16 && second <= 31) || - (first === 192 && second === 168); - if (isIP(parsed.data.ip) !== 4 || nonPublic) { - throw new Error('Public IPv4 discovery did not return a valid public IPv4 address'); - } - return parsed.data.ip; -} - -/** Discovers account and operator context without creating or changing Azure resources. */ -export async function initDemoPublication( - input: DemoInitOptions, - runner: Run, - fetcher: typeof fetch = fetch, - newId: () => string = randomUUID, -): Promise { - const options = demoInitOptionsSchema.parse(input); - const account = accountSchema.parse(JSON.parse(await runner('az', [ - 'account', 'show', '--subscription', options.subscriptionId, - '--query', '{id:id,tenantId:tenantId}', '-o', 'json', - ]))); - if (account.id !== options.subscriptionId) { - throw new Error('Azure CLI account context does not match the exact requested subscription'); - } - const token = tokenSchema.parse(JSON.parse(await runner('az', [ - 'account', 'get-access-token', '--subscription', options.subscriptionId, - '--resource', 'https://management.azure.com/', '-o', 'json', - ], { sensitiveOutput: true }))); - const claims = decodeClaims(token.accessToken); - const schemaRunnerIp = await discoverPublicIpv4(fetcher); - const deploymentId = guid.parse(newId()); - const tokenPart = deploymentId.replaceAll('-', '').slice(0, 12); - const short = deploymentId.slice(0, 8); - const registryServer = `sqlappsdemo${tokenPart}.azurecr.io`; - - return demoPublicationSchema.parse({ - version: 1, - profile: 'public-demo', - securityPosture: 'starter', - hosting: 'static-web-apps', - name: 'sqlapps', - subscriptionId: options.subscriptionId, - tenantId: account.tenantId, - resourceGroup: options.resourceGroup, - location: options.location, - staticWebAppLocation: options.staticWebAppLocation, - deploymentId, - applicationName: `sqlapps-${short}-web`, - staticWebAppName: `sqlapps-${short}-static`, - registryServer, - gatewayImage: `${registryServer}/sqlapps-${options.selectedApplication}-api:${short}`, - dabImage: `${registryServer}/sqlapps-${options.selectedApplication}-dab:${short}`, - selectedApplication: options.selectedApplication, - selectedArtifact: resolve(options.selectedArtifact), - sqlAdminObjectId: claims.oid, - sqlAdminName: claims.name ?? claims.preferred_username, - schemaRunnerIp, - cost: { intent: 'review-paid-costs', acknowledgeFixedCharges: true }, - sqlBillingMode: 'free-paused', - sqlSku: { name: 'GP_S_Gen5_2', tier: 'GeneralPurpose', family: 'Gen5', capacity: 2 }, - sqlMaxSizeBytes: 32 * 1024 ** 3, - sqlBackupRedundancy: 'Local', - tags: { environment: 'synthetic-demo', owner: options.owner }, - }); -} - -export async function createDemoPublicationFile( - path: string, - input: DemoInitOptions, - runner: Run, - fetcher: typeof fetch = fetch, - newId: () => string = randomUUID, -): Promise { - const publication = await initDemoPublication(input, runner, fetcher, newId); - await createJsonFile(path, publication); - return publication; -} diff --git a/src/demo-publication.ts b/src/demo-publication.ts deleted file mode 100644 index 9cbf1a5..0000000 --- a/src/demo-publication.ts +++ /dev/null @@ -1,494 +0,0 @@ -import { createHash } from 'node:crypto'; -import { cp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'; -import { readFileSync, realpathSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { z } from 'zod'; -import { checkSelectedApplication, type ApplicationBuildManifest } from './application-build.js'; -import { preflightDemo } from './azure-preflight.js'; -import { reviewDemoCost } from './demo-cost.js'; -import { - acceptDemoRuntime, demoRuntimeSchema, deployDemoRuntime, verifyDemoRuntime, -} from './demo-runtime.js'; -import { saveJson, withDeploymentLock } from './deployment.js'; -import type { Run } from './process.js'; - -const guid = z.string().uuid().transform(value => value.toLowerCase()); -const imageReference = z.string().regex( - /^[a-z0-9]+\.azurecr\.io\/[a-z0-9]+(?:[._/-][a-z0-9]+)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}|@sha256:[a-f0-9]{64})$/, - 'Image must use an explicit tag or sha256 digest', -); -const sku = z.strictObject({ - name: z.string().regex(/^[A-Za-z0-9_]{1,64}$/), - tier: z.string().regex(/^[A-Za-z]{1,32}$/), - family: z.string().regex(/^[A-Za-z0-9]{1,32}$/).optional(), - capacity: z.number().finite().positive(), -}); - -export const demoPublicationSchema = z.strictObject({ - version: z.literal(1), - profile: z.literal('public-demo'), - securityPosture: z.literal('starter'), - hosting: z.literal('static-web-apps'), - name: z.string().regex(/^[a-z][a-z0-9-]{2,15}$/), - subscriptionId: guid, - tenantId: guid, - resourceGroup: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.()-]{0,89}$/).refine(value => !value.endsWith('.')), - location: z.string().regex(/^[a-z0-9]+$/), - staticWebAppLocation: z.string().regex(/^[a-z0-9]+$/), - deploymentId: guid, - applicationName: z.string().regex(/^[a-z][a-z0-9-]{2,39}$/), - staticWebAppName: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{2,39}$/), - registryServer: z.string().regex(/^[a-z0-9]+\.azurecr\.io$/), - gatewayImage: imageReference, - dabImage: imageReference, - selectedApplication: z.string().regex(/^[a-z][a-z0-9-]{0,31}$/), - selectedArtifact: z.string().min(1), - sqlAdminObjectId: guid, - sqlAdminName: z.string().trim().min(1).max(128), - schemaRunnerIp: z.ipv4(), - cost: z.strictObject({ - intent: z.literal('review-paid-costs'), - acknowledgeFixedCharges: z.literal(true), - }), - sqlBillingMode: z.enum(['free-paused', 'paid-reviewed']), - sqlSku: sku, - sqlMaxSizeBytes: z.number().int().min(104_857_600).max(Number.MAX_SAFE_INTEGER), - sqlBackupRedundancy: z.enum(['Local', 'Zone', 'Geo', 'GeoZone']), - tags: z.record(z.string().regex(/^[A-Za-z0-9_.-]{1,128}$/), z.string().max(256)).default({}), -}).superRefine((value, context) => { - for (const field of ['gatewayImage', 'dabImage'] as const) { - if (!value[field].startsWith(`${value.registryServer}/`)) { - context.addIssue({ code: 'custom', path: [field], message: 'Image must belong to the configured ACR' }); - } - } - if (value.gatewayImage === value.dabImage) { - context.addIssue({ code: 'custom', path: ['dabImage'], message: 'Gateway and DAB images must use distinct references' }); - } - if (value.sqlBillingMode === 'free-paused' && - (value.sqlSku.name !== 'GP_S_Gen5_2' || value.sqlSku.tier !== 'GeneralPurpose' || - value.sqlSku.family !== 'Gen5' || value.sqlSku.capacity !== 2 || - value.sqlMaxSizeBytes !== 32 * 1024 ** 3 || value.sqlBackupRedundancy !== 'Local')) { - context.addIssue({ code: 'custom', path: ['sqlBillingMode'], - message: 'free-paused must carry the exact reviewed free SQL sizing parameters' }); - } -}); -export type DemoPublicationConfig = z.infer; - -export const demoPublicationStages = ['infrastructure', 'artifacts', 'schema', 'backend', 'static', 'ready'] as const; -export type DemoPublicationStage = typeof demoPublicationStages[number]; -const demoPublicationStateStages = ['planned', ...demoPublicationStages] as const; -type DemoPublicationStateStage = typeof demoPublicationStateStages[number]; - -const infrastructureSchema = z.strictObject({ - environmentId: z.string().startsWith('/subscriptions/'), - environmentName: z.string().min(1), - applicationName: z.string().min(1), - runtimeIdentityResourceId: z.string().startsWith('/subscriptions/'), - runtimeIdentityName: z.string().regex(/^[a-zA-Z0-9_-]{1,128}$/), - runtimeIdentityClientId: guid, - runtimeIdentityPrincipalId: guid, - runtimeIdentityTenantId: guid, - registryId: z.string().startsWith('/subscriptions/'), - registryName: z.string().min(1), - registryServer: z.string().regex(/^[a-z0-9]+\.azurecr\.io$/), - networkId: z.string().startsWith('/subscriptions/'), - subnetId: z.string().startsWith('/subscriptions/'), - sqlServerId: z.string().startsWith('/subscriptions/'), - sqlServerName: z.string().regex(/^[a-z0-9-]{1,63}$/), - sqlHost: z.string().regex(/^[a-z0-9-]+\.database\.windows\.net$/), - databaseId: z.string().startsWith('/subscriptions/'), - databaseName: z.literal('app'), - ownershipTags: z.record(z.string(), z.string()), - sqlBillingMode: z.enum(['free-paused', 'paid-reviewed']), - logsWorkspaceId: z.string().startsWith('/subscriptions/'), - logsWorkspaceName: z.string().min(1), -}); -const pinnedImagesSchema = z.strictObject({ - gatewayImage: z.string().regex(/@sha256:[a-f0-9]{64}$/), - dabImage: z.string().regex(/@sha256:[a-f0-9]{64}$/), -}); - -export const demoPublicationStateSchema = z.strictObject({ - version: z.literal(1), - profile: z.literal('public-demo'), - securityPosture: z.enum(['starter', 'hardened', 'partially-hardened']), - registryAuthentication: z.enum(['credential', 'managed-identity']), - acrAdminEnabled: z.boolean(), - stage: z.enum(demoPublicationStateStages), - workspaceHome: z.string().min(1), - configHash: z.string().regex(/^[a-f0-9]{64}$/), - artifactHash: z.string().regex(/^[a-f0-9]{64}$/), - pinnedImages: pinnedImagesSchema.nullable(), - infrastructure: infrastructureSchema.nullable(), - runtime: demoRuntimeSchema.nullable(), - updatedAt: z.iso.datetime(), -}); -export type DemoPublicationState = z.infer; -export type DemoInfrastructure = z.infer; -export type DemoPinnedImages = z.infer; - -export interface PublicationPlan { - approval: string; - pending: DemoPublicationStage[]; - excluded: string[]; - review: string[]; - warning: string; - configHash: string; - artifactHash: string; -} - -const hash = (value: string | Buffer) => createHash('sha256').update(value).digest('hex'); -const parsedConfig = (input: DemoPublicationConfig) => demoPublicationSchema.parse(input); -const stageIndex = (stage: DemoPublicationStateStage) => demoPublicationStateStages.indexOf(stage); - -export type DemoPublicationPlanningInput = - Omit & { securityPosture: 'starter' | 'hardened' }; - -function currentBinding(parsed: DemoPublicationPlanningInput) { - const workspaceHome = realpathSync('.'); - const artifact = resolve(parsed.selectedArtifact); - const artifactManifest = readFileSync(join(artifact, 'application-build.json')); - return { - parsed, workspaceHome, - configHash: hash(JSON.stringify(parsed)), - artifactHash: hash(Buffer.concat([Buffer.from(realpathSync(artifact)), Buffer.from([0]), artifactManifest])), - }; -} - -export function demoPublicationStatePath(config: DemoPublicationConfig): string { - const parsed = parsedConfig(config); - return resolve('.sql-apps', 'demo-publications', - `${hash(`${parsed.subscriptionId}\0${parsed.resourceGroup}\0${parsed.deploymentId}`).slice(0, 20)}.json`); -} - -function matchingState(state: DemoPublicationState | undefined, binding: ReturnType) { - return state && state.workspaceHome === binding.workspaceHome && state.configHash === binding.configHash && - state.artifactHash === binding.artifactHash ? state : undefined; -} - -function approvalId(binding: ReturnType, state?: DemoPublicationState) { - const matched = matchingState(state, binding); - const pending = demoPublicationStages.slice(matched ? stageIndex(matched.stage) : 0); - return hash(JSON.stringify({ - version: 1, securityPosture: binding.parsed.securityPosture, - configHash: binding.configHash, artifactHash: binding.artifactHash, - workspaceHome: binding.workspaceHome, pending, - })).slice(0, 24); -} - -export function demoPublicationApprovalId( - input: DemoPublicationPlanningInput, state?: DemoPublicationState, -): string { - return approvalId(currentBinding(input), state); -} - -export function planDemoPublication( - config: DemoPublicationConfig, state?: DemoPublicationState, -): PublicationPlan { - const binding = currentBinding(parsedConfig(config)); - const matched = matchingState(state, binding); - const pending = demoPublicationStages.slice(matched ? stageIndex(matched.stage) : 0); - return { - approval: approvalId(binding, state), pending: [...pending], - review: [ - `Target: subscription ${binding.parsed.subscriptionId}, resource group ${binding.parsed.resourceGroup}, backend ${binding.parsed.location}, Static Web Apps ${binding.parsed.staticWebAppLocation}`, - 'Security posture: Starter; ACR image pulls use an Azure-managed Container App secret until separately approved hardening', - 'Costs: paid fixed charges are acknowledged for review; actual regional/account pricing and grant consumption still require operator confirmation', - `Images: publish or verify ${binding.parsed.gatewayImage} and ${binding.parsed.dabImage} from ${realpathSync(binding.parsed.selectedArtifact)}, then deploy immutable digests`, - 'Resources: managed identity, VNet/subnet, Container Apps environment, ACR Basic, Azure SQL, Static Web Apps Standard, and one linked two-container app', - `Schema: temporarily allow only ${binding.parsed.schemaRunnerIp}, publish with Microsoft Entra authentication, then remove that firewall rule before runtime deployment`, - 'Cleanup: no automatic teardown; export required synthetic data, then separately approve deletion of only resources tagged with this deployment ID', - ], - excluded: [ - 'No real/customer data, production SLA, custom domain, paid SQL fallback, production hardening, or default-hosting change', - 'No deployment token is accepted, printed, or persisted', - ], - warning: 'PUBLIC SYNTHETIC DEMO ONLY. Azure Static Web Apps Standard, ACR, networking, SQL, and runtime usage can incur charges.', - configHash: binding.configHash, artifactHash: binding.artifactHash, - }; -} - -export async function readDemoPublicationState(config: DemoPublicationConfig): Promise { - const binding = currentBinding(parsedConfig(config)); - const state = demoPublicationStateSchema.parse(JSON.parse(await readFile(demoPublicationStatePath(config), 'utf8'))); - if (!matchingState(state, binding)) { - throw new Error('Publication state belongs to another workspace, configuration, or selected artifact'); - } - return state; -} - -function outputs(text: string, schema: z.ZodType): T { - const envelope = z.record(z.string(), z.object({ value: z.unknown() })).parse(JSON.parse(text)); - return schema.parse(Object.fromEntries(Object.entries(envelope).map(([key, item]) => [key, item.value]))); -} - -async function parameterFile(config: DemoPublicationConfig, suffix: string, parameters: Record) { - const path = `${demoPublicationStatePath(config)}.${suffix}.parameters.json`; - await saveJson(path, { parameters: Object.fromEntries(Object.entries(parameters).map(([key, value]) => [key, { value }])) }); - return path; -} - -async function pinImage(config: DemoPublicationConfig, field: 'gatewayImage' | 'dabImage', runner: Run) { - const image = config[field]; - const reference = image.slice(config.registryServer.length + 1); - const digest = (await runner('az', ['acr', 'repository', 'show', '--subscription', config.subscriptionId, - '--name', config.registryServer.split('.')[0]!, '--image', reference, '--query', 'digest', '-o', 'tsv'])).trim(); - if (!/^sha256:[a-f0-9]{64}$/.test(digest)) throw new Error(`ACR returned an invalid digest for ${field}`); - if (image.includes('@') && image.split('@')[1] !== digest) throw new Error(`Configured ${field} digest does not match ACR`); - const repository = reference.split('@')[0]!.split(':')[0]!; - return `${config.registryServer}/${repository}@${digest}`; -} - -async function publishImages(config: DemoPublicationConfig, runner: Run) { - const fields = [ - ['gatewayImage', 'Dockerfile'], - ['dabImage', 'dab/Dockerfile'], - ] as const; - for (const [field, dockerfile] of fields) { - if (!config[field].includes('@')) { - await runner('az', ['acr', 'build', '--subscription', config.subscriptionId, - '--registry', config.registryServer.split('.')[0]!, - '--image', config[field].slice(config.registryServer.length + 1), - '--file', dockerfile, resolve(config.selectedArtifact)]); - } - } - return pinnedImagesSchema.parse({ - gatewayImage: await pinImage(config, 'gatewayImage', runner), - dabImage: await pinImage(config, 'dabImage', runner), - }); -} - -function guidSid(value: string) { - const bytes = Buffer.from(value.replaceAll('-', ''), 'hex'); - return Buffer.concat([ - Buffer.from([...bytes.subarray(0, 4)].reverse()), - Buffer.from([...bytes.subarray(4, 6)].reverse()), - Buffer.from([...bytes.subarray(6, 8)].reverse()), - bytes.subarray(8), - ]).toString('hex').toUpperCase(); -} - -async function publishSelectedSchema( - config: DemoPublicationConfig, manifest: ApplicationBuildManifest, - infrastructure: z.infer, runner: Run, -) { - const directory = `${demoPublicationStatePath(config)}.schema`; - await rm(directory, { recursive: true, force: true }); - await mkdir(directory, { recursive: true }); - await cp(join(resolve(config.selectedArtifact), 'sql'), directory, { recursive: true, dereference: false }); - const postDeploy = join(directory, 'post-deploy.sql'); - const grants = (await readFile(join(resolve(config.selectedArtifact), 'setup', 'grants.sql'), 'utf8')) - .replaceAll('$(ApplicationUser)', infrastructure.runtimeIdentityName.replaceAll(']', ']]')); - const principal = infrastructure.runtimeIdentityName.replaceAll(']', ']]'); - await writeFile(postDeploy, `${await readFile(postDeploy, 'utf8')} -IF DATABASE_PRINCIPAL_ID(N'${principal.replaceAll("'", "''")}') IS NULL - CREATE USER [${principal}] WITH SID = 0x${guidSid(infrastructure.runtimeIdentityPrincipalId)}, TYPE = E; -GO -${grants} -`); - const output = join(directory, 'output'); - await runner('dotnet', ['build', join(directory, 'database.sqlproj'), '--output', output]); - const tokenText = await runner('az', ['account', 'get-access-token', '--tenant', config.tenantId, - '--subscription', config.subscriptionId, '--resource', 'https://database.windows.net/', '-o', 'json']); - const accessToken = z.object({ accessToken: z.string().min(1) }).parse(JSON.parse(tokenText)).accessToken; - const firewallRule = `${config.name}-${config.deploymentId.slice(0, 8)}-schema-runner`; - await runner('az', ['sql', 'server', 'firewall-rule', 'create', - '--subscription', config.subscriptionId, '--resource-group', config.resourceGroup, - '--server', infrastructure.sqlServerName, '--name', firewallRule, - '--start-ip-address', config.schemaRunnerIp, '--end-ip-address', config.schemaRunnerIp, - ]); - let publishError: unknown; - try { - await runner('dotnet', ['tool', 'run', 'sqlpackage', '--', '/Action:Publish', - `/SourceFile:${join(output, 'database.dacpac')}`, - `/TargetConnectionString:Server=tcp:${infrastructure.sqlHost},1433;Database=${infrastructure.databaseName};Encrypt=True;TrustServerCertificate=False;`, - `/AccessToken:${accessToken}`, '/p:BlockOnPossibleDataLoss=True', '/p:DropObjectsNotInSource=False', - ], { redact: [accessToken] }); - } catch (error) { - publishError = error; - } - try { - await runner('az', ['sql', 'server', 'firewall-rule', 'delete', - '--subscription', config.subscriptionId, '--resource-group', config.resourceGroup, - '--server', infrastructure.sqlServerName, '--name', firewallRule, - ]); - } catch (cleanupError) { - throw new AggregateError( - publishError === undefined ? [cleanupError] : [publishError, cleanupError], - 'Schema publication failed or its temporary SQL firewall rule could not be removed', - ); - } - if (publishError !== undefined) throw publishError; - if (manifest.name !== config.selectedApplication) throw new Error('Selected artifact application does not match publication configuration'); -} - -export async function validateDemoPublication( - config: DemoPublicationConfig, -): Promise<{ config: DemoPublicationConfig; manifest: ApplicationBuildManifest }> { - const parsed = parsedConfig(config); - const home = await realpath('.'); - const manifest = await checkSelectedApplication(home, resolve(parsed.selectedArtifact), 'public-demo'); - if (manifest.name !== parsed.selectedApplication) throw new Error('Selected artifact application does not match publication configuration'); - const cost = reviewDemoCost({ - version: 1, profile: parsed.profile, hosting: parsed.hosting, - intent: parsed.cost.intent, acknowledgeFixedCharges: parsed.cost.acknowledgeFixedCharges, - sql: parsed.sqlBillingMode === 'free-paused' ? { mode: parsed.sqlBillingMode } : { - mode: parsed.sqlBillingMode, sku: parsed.sqlSku, - maxSizeBytes: parsed.sqlMaxSizeBytes, backupRedundancy: parsed.sqlBackupRedundancy, - }, - }); - if (!cost.review.canProceedToWhatIf) throw new Error('Publication requires an acknowledged paid-cost review'); - return { config: parsed, manifest }; -} - -export async function publishDemo( - input: DemoPublicationConfig, runner: Run, fetcher: typeof fetch = fetch, expectedApproval?: string, -): Promise { - const config = parsedConfig(input); - const binding = currentBinding(config); - const path = demoPublicationStatePath(config); - return withDeploymentLock(path, async () => { - const validated = await validateDemoPublication(config); - const assertArtifact = async () => { - await checkSelectedApplication(binding.workspaceHome, resolve(config.selectedArtifact), 'public-demo'); - const current = currentBinding(config); - if (current.workspaceHome !== binding.workspaceHome || current.configHash !== binding.configHash || - current.artifactHash !== binding.artifactHash) { - throw new Error('Publication configuration or selected artifact changed during publication'); - } - }; - let previous: DemoPublicationState | undefined; - try { previous = await readDemoPublicationState(config); } - catch (error) { - if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; - } - if (expectedApproval !== undefined && planDemoPublication(config, previous).approval !== expectedApproval) { - throw new Error('The configuration, selected artifact, or pending publication work changed after review'); - } - await preflightDemo({ - profile: config.profile, tenantId: config.tenantId, subscriptionId: config.subscriptionId, - resourceGroup: config.resourceGroup, location: config.location, - staticWebAppLocation: config.staticWebAppLocation, - securityPosture: config.securityPosture, - }, runner, fetcher); - await runner('az', ['bicep', 'version']); - await runner('dotnet', ['tool', 'run', 'sqlpackage', '--', '/Version']); - await runner('npx', ['--no-install', 'swa', '--version'], { - env: { SWA_CLI_DEPLOYMENT_TOKEN: '', AZURE_STATIC_WEB_APPS_API_TOKEN: '' }, - }); - - let state = matchingState(previous, binding) ?? demoPublicationStateSchema.parse({ - version: 1, profile: 'public-demo', securityPosture: 'starter', - registryAuthentication: 'credential', acrAdminEnabled: true, - stage: 'planned', workspaceHome: binding.workspaceHome, - configHash: binding.configHash, artifactHash: binding.artifactHash, - pinnedImages: null, infrastructure: null, runtime: null, updatedAt: new Date().toISOString(), - }); - const persist = async (next: DemoPublicationState) => { - state = demoPublicationStateSchema.parse({ ...next, updatedAt: new Date().toISOString() }); - await saveJson(path, state); - }; - if (state.stage === 'planned') { - const parameters = { - name: config.name, selectedApplication: config.selectedApplication, deploymentId: config.deploymentId, - registryName: config.registryServer.split('.')[0]!, - location: config.location, tenantId: config.tenantId, tags: config.tags, - sqlAdminObjectId: config.sqlAdminObjectId, sqlAdminName: config.sqlAdminName, - sqlBillingMode: config.sqlBillingMode, sqlSku: config.sqlSku, - sqlMaxSizeBytes: config.sqlMaxSizeBytes, sqlBackupRedundancy: config.sqlBackupRedundancy, - securityPosture: config.securityPosture, - }; - const infrastructure = outputs(await runner('az', ['deployment', 'group', 'create', - '--subscription', config.subscriptionId, '--resource-group', config.resourceGroup, - '--name', `${config.name}-${config.deploymentId.slice(0, 8)}-infrastructure`, - '--template-file', 'infra/demo.bicep', - '--parameters', `@${await parameterFile(config, 'infrastructure', parameters)}`, - '--query', 'properties.outputs', '-o', 'json', - ]), infrastructureSchema); - if (infrastructure.applicationName !== config.applicationName || - infrastructure.registryName !== config.registryServer.split('.')[0] || - infrastructure.registryServer !== config.registryServer || - infrastructure.runtimeIdentityTenantId !== config.tenantId || - infrastructure.sqlBillingMode !== config.sqlBillingMode) { - throw new Error('Provisioned foundation outputs do not match the reviewed publication target'); - } - await persist({ ...state, stage: 'infrastructure', infrastructure }); - } - if (stageIndex(state.stage) < stageIndex('artifacts')) { - const pinnedImages = await publishImages(config, runner); - await assertArtifact(); - await persist({ ...state, stage: 'artifacts', pinnedImages }); - } - if (stageIndex(state.stage) < stageIndex('schema')) { - if (!state.infrastructure) throw new Error('Infrastructure state is required before schema publication'); - await assertArtifact(); - await publishSelectedSchema(config, validated.manifest, state.infrastructure, runner); - await persist({ ...state, stage: 'schema' }); - } - if (stageIndex(state.stage) < stageIndex('backend')) { - if (!state.infrastructure || !state.pinnedImages) throw new Error('Foundation and pinned images are required before backend deployment'); - const runtime = await deployDemoRuntime( - config, state.infrastructure, state.pinnedImages, config.securityPosture, runner, - parameters => parameterFile(config, 'runtime', parameters), - ); - await verifyDemoRuntime(runtime, fetcher); - await persist({ ...state, stage: 'backend', runtime }); - } - if (stageIndex(state.stage) < stageIndex('static')) { - if (!state.runtime) throw new Error('Ready backend state is required before static deployment'); - await assertArtifact(); - const deploymentToken = (await runner('az', ['staticwebapp', 'secrets', 'list', - '--subscription', config.subscriptionId, '--resource-group', config.resourceGroup, - '--name', config.staticWebAppName, '--query', 'properties.apiKey', '-o', 'tsv'], - { sensitiveOutput: true })).trim(); - if (deploymentToken.length < 20 || /\s/.test(deploymentToken)) { - throw new Error('Azure returned an invalid Static Web Apps deployment token'); - } - await runner('npx', ['--no-install', 'swa', 'deploy', join(resolve(config.selectedArtifact), 'static'), - '--env', 'production', '--no-use-keychain'], { - env: { - SWA_CLI_DEPLOYMENT_TOKEN: deploymentToken, AZURE_STATIC_WEB_APPS_API_TOKEN: '', - AZURE_CLIENT_ID: '', AZURE_CLIENT_SECRET: '', AZURE_TENANT_ID: '', AZURE_SUBSCRIPTION_ID: '', - }, - redact: [deploymentToken], - }); - await verifyDemoRuntime(state.runtime, fetcher, config.selectedArtifact); - await persist({ ...state, stage: 'static' }); - } - if (stageIndex(state.stage) < stageIndex('ready')) { - if (!state.runtime) throw new Error('Static acceptance requires saved runtime outputs'); - await acceptDemoRuntime(state.runtime, config.selectedArtifact, fetcher); - await persist({ ...state, stage: 'ready' }); - } - return state; - }); -} - -export async function demoPublicationStatus( - config: DemoPublicationConfig, runner: Run, fetcher: typeof fetch = fetch, -) { - const state = await readDemoPublicationState(config); - const resources: Record = {}; - if (state.infrastructure) { - resources.foundation = JSON.parse(await runner('az', ['deployment', 'group', 'show', - '--subscription', config.subscriptionId, '--resource-group', config.resourceGroup, - '--name', `${config.name}-${config.deploymentId.slice(0, 8)}-infrastructure`, - '--query', '{provisioningState:properties.provisioningState,timestamp:properties.timestamp}', '-o', 'json'])); - } - if (state.runtime) { - resources.runtime = JSON.parse(await runner('az', ['containerapp', 'show', - '--subscription', config.subscriptionId, '--resource-group', config.resourceGroup, - '--name', config.applicationName, - '--query', '{provisioningState:properties.provisioningState,runningStatus:properties.runningStatus}', '-o', 'json'])); - const [backend, publicSite, direct] = await Promise.all([ - fetcher(new URL('/api/health/ready', state.runtime.publicUrl), { signal: AbortSignal.timeout(5_000), redirect: 'error' }), - fetcher(new URL('/', state.runtime.publicUrl), { signal: AbortSignal.timeout(5_000), redirect: 'error' }), - fetcher(new URL('/api/session', state.runtime.backendUrl), { - method: 'POST', headers: { 'content-type': 'application/json', origin: 'https://direct.invalid' }, - body: JSON.stringify({ synthetic: true }), signal: AbortSignal.timeout(5_000), redirect: 'error', - }), - ]); - resources.health = { linkedBackend: backend.status, publicSite: publicSite.status, directBackend: direct.status }; - } - return { stage: state.stage, updatedAt: state.updatedAt, resources }; -} diff --git a/src/demo-runtime.ts b/src/demo-runtime.ts deleted file mode 100644 index 4f2544c..0000000 --- a/src/demo-runtime.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { createHash } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { z } from 'zod'; -import type { - DemoInfrastructure, DemoPinnedImages, DemoPublicationConfig, DemoPublicationPlanningInput, -} from './demo-publication.js'; -import type { Run } from './process.js'; - -export const demoRuntimeOutputSchema = z.strictObject({ - applicationId: z.string().startsWith('/subscriptions/'), - publicUrl: z.string().url().refine(value => new URL(value).protocol === 'https:'), - backendUrl: z.string().url().refine(value => new URL(value).protocol === 'https:'), - staticWebAppName: z.string().min(1), -}); -export const demoRuntimeSchema = demoRuntimeOutputSchema.extend({ - staticWebAppId: z.string().startsWith('/subscriptions/'), -}); -export type DemoRuntime = z.infer; - -export function demoRuntimeParameters( - config: DemoPublicationConfig, - infrastructure: DemoInfrastructure, - pinnedImages: DemoPinnedImages, - securityPosture: DemoPublicationPlanningInput['securityPosture'], -) { - return { - applicationName: config.applicationName, - selectedApplication: config.selectedApplication, - deploymentId: config.deploymentId, - location: config.location, - staticWebAppLocation: config.staticWebAppLocation, - tags: config.tags, - environmentName: infrastructure.environmentName, - runtimeIdentityName: infrastructure.runtimeIdentityName, - registryName: infrastructure.registryName, - sqlServerName: infrastructure.sqlServerName, - databaseName: infrastructure.databaseName, - staticWebAppName: config.staticWebAppName, - gatewayImage: pinnedImages.gatewayImage, - dabImage: pinnedImages.dabImage, - securityPosture, - }; -} - -function deploymentOutputs(text: string) { - const envelope = z.record(z.string(), z.object({ value: z.unknown() })).parse(JSON.parse(text)); - return demoRuntimeOutputSchema.parse( - Object.fromEntries(Object.entries(envelope).map(([key, item]) => [key, item.value])), - ); -} - -export async function deployDemoRuntime( - config: DemoPublicationConfig, - infrastructure: DemoInfrastructure, - pinnedImages: DemoPinnedImages, - securityPosture: DemoPublicationPlanningInput['securityPosture'], - runner: Run, - createParameterFile: (parameters: Record) => Promise, -): Promise { - const parameters = demoRuntimeParameters(config, infrastructure, pinnedImages, securityPosture); - const deployed = deploymentOutputs(await runner('az', ['deployment', 'group', 'create', - '--subscription', config.subscriptionId, '--resource-group', config.resourceGroup, - '--name', `${config.name}-${config.deploymentId.slice(0, 8)}-runtime`, - '--template-file', 'infra/demo-runtime.bicep', - '--parameters', `@${await createParameterFile(parameters)}`, - '--query', 'properties.outputs', '-o', 'json', - ])); - const runtime = demoRuntimeSchema.parse({ - ...deployed, - staticWebAppId: `/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}` + - `/providers/Microsoft.Web/staticSites/${deployed.staticWebAppName}`, - }); - if (runtime.staticWebAppName !== config.staticWebAppName) { - throw new Error('Runtime output selected another Static Web App'); - } - return runtime; -} - -async function waitForBackend(runtime: DemoRuntime, fetcher: typeof fetch) { - for (let attempt = 0; attempt < 12; attempt++) { - try { - const response = await fetcher(new URL('/api/health/ready', runtime.publicUrl), - { signal: AbortSignal.timeout(5_000), redirect: 'error' }); - if (response.ok) break; - if (![502, 503, 504].includes(response.status)) throw new Error(`Backend readiness returned ${response.status}`); - } catch (error) { - if (error instanceof Error && error.message.startsWith('Backend readiness returned')) throw error; - if (attempt === 11) throw new Error('Backend readiness did not become reachable'); - } - if (attempt === 11) throw new Error('Backend readiness did not pass'); - await new Promise(done => setTimeout(done, 5_000)); - } - const denied = await fetcher(new URL('/api/session', runtime.backendUrl), { - method: 'POST', headers: { 'content-type': 'application/json', origin: 'https://direct.invalid' }, - body: JSON.stringify({ synthetic: true }), signal: AbortSignal.timeout(5_000), redirect: 'error', - }); - if (![401, 403].includes(denied.status)) throw new Error(`Direct backend API was not denied (${denied.status})`); -} - -const hash = (value: string | Buffer) => createHash('sha256').update(value).digest('hex'); - -async function verifyStaticContent(runtime: DemoRuntime, artifact: string, fetcher: typeof fetch) { - const expected = hash(await readFile(join(resolve(artifact), 'static', 'app.js'))); - for (let attempt = 0; attempt < 12; attempt++) { - try { - const url = new URL('/app.js', runtime.publicUrl); - url.searchParams.set('sql-apps-verify', expected); - const response = await fetcher(url, { signal: AbortSignal.timeout(5_000), redirect: 'error' }); - if (response.ok && hash(Buffer.from(await response.arrayBuffer())) === expected) return; - if (![404, 502, 503, 504].includes(response.status)) { - throw new Error(`Static content verification returned ${response.status}`); - } - } catch (error) { - if (error instanceof Error && error.message.startsWith('Static content verification returned')) throw error; - if (attempt === 11) throw new Error('Static content did not become reachable'); - } - if (attempt === 11) throw new Error('Static content did not match the reviewed artifact'); - await new Promise(done => setTimeout(done, 5_000)); - } -} - -export async function verifyDemoRuntime( - runtime: DemoRuntime, fetcher: typeof fetch, artifact?: string, -): Promise { - if (artifact === undefined) await waitForBackend(runtime, fetcher); - else await verifyStaticContent(runtime, artifact, fetcher); -} - -export async function acceptDemoRuntime( - runtime: DemoRuntime, artifact: string, fetcher: typeof fetch, -): Promise { - const origin = new URL(runtime.publicUrl).origin; - const request = (path: string, init: RequestInit = {}) => fetcher(new URL(path, origin), { - ...init, headers: { origin, ...init.headers }, signal: AbortSignal.timeout(10_000), redirect: 'error', - }); - const home = await request('/'); - if (!home.ok) throw new Error(`Static application returned ${home.status}`); - const started = await request('/api/session', { - method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ synthetic: true }), - }); - if (started.status !== 201) throw new Error(`Public session creation returned ${started.status}`); - const setCookie = started.headers.get('set-cookie'); - const cookie = setCookie?.split(';', 1)[0]; - if (!cookie || !/^__Host-sql_apps_session=/.test(cookie) || - !setCookie || !/;\s*secure(?:;|$)/i.test(setCookie) || !/;\s*httponly(?:;|$)/i.test(setCookie) || - !/;\s*samesite=strict(?:;|$)/i.test(setCookie) || !/;\s*path=\/(?:;|$)/i.test(setCookie)) { - throw new Error('Public session did not issue the expected secure cookie'); - } - const foreignOrigin = await fetcher(new URL('/api/session', origin), { - method: 'POST', headers: { 'content-type': 'application/json', cookie, origin: 'https://foreign.invalid' }, - body: JSON.stringify({ synthetic: true }), signal: AbortSignal.timeout(10_000), redirect: 'error', - }); - if (foreignOrigin.status !== 403) throw new Error(`Foreign-origin mutation was not rejected (${foreignOrigin.status})`); - const forgedIdentity = await request('/api/session', { - method: 'POST', - headers: { 'content-type': 'application/json', cookie, 'x-ms-client-principal': 'forged', 'x-ms-api-role': 'anonymous' }, - body: JSON.stringify({ synthetic: true }), - }); - if (forgedIdentity.status !== 403) throw new Error(`Forged platform identity headers were not rejected (${forgedIdentity.status})`); - const ended = await request('/api/session', { method: 'DELETE', headers: { cookie } }); - if (ended.status !== 204) throw new Error(`Public session cleanup returned ${ended.status}`); - const expired = await request('/api/session', { headers: { cookie } }); - if (expired.status !== 401) throw new Error(`Deleted public session remained usable (${expired.status})`); - const acceptance = z.object({ - acceptPublicApplication: z.custom<(origin: string, fetcher: typeof fetch) => Promise>( - value => typeof value === 'function', - ), - }).parse(await import(pathToFileURL(join(resolve(artifact), 'acceptance', 'public.mjs')).href)); - await acceptance.acceptPublicApplication(origin, fetcher); - await waitForBackend(runtime, fetcher); -} diff --git a/src/deployment.ts b/src/deployment.ts index 7e463b6..67a7cbf 100644 --- a/src/deployment.ts +++ b/src/deployment.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; import { deploymentSchema, environmentKey, type DeploymentConfig } from './config.js'; import { authRedirectPath } from './browser-config.js'; import { type Run } from './process.js'; -import { imageDigest, deploymentImageFields } from './artifacts.js'; +import { imageDigest, imageFields } from './artifacts.js'; import { validateRoleBasedCloudDab, roleBasedProcedureGrants, roleBasedSchemaFingerprint } from './role-based-profile.js'; import { withFileLock } from './file-lock.mjs'; import { toolingAsset } from './runtime-roots.js'; @@ -17,19 +17,16 @@ const outputSchema = z.object({ gatewayPrincipalId: z.string().uuid(), gatewayName: z.string(), gatewayUrl: z.string(), - functionsName: z.string(), networkId: z.string(), - storageAccount: z.string(), - vaultName: z.string(), sqlBackupRetentionDays: z.number().int().min(1).max(35), sqlBackupRedundancy: z.enum(['Local', 'Zone', 'Geo', 'GeoZone']), sqlAuditWorkspaceName: z.string().min(1), }); export type DeploymentOutputs = z.infer; -export const functionRoleId = '15d91b1c-83c7-4cb6-b349-d7b8fd4b1425'; export const delegatedScopeId = '6bb296dd-f397-4dcc-a69b-d37dfce3c555'; export const applicationRoleId = '530d74b7-cf30-41f4-a44c-9f594cba7c63'; export const deploymentTemplatePath = () => toolingAsset('infra', 'main.bicep'); +export const rbacTemplatePath = () => toolingAsset('infra', 'rbac.bicep'); const deploymentProjectRoot = () => process.env.SQL_APPS_PROJECT_HOME ?? process.cwd(); const deploymentProjectPath = (...parts: string[]) => resolve(deploymentProjectRoot(), ...parts); @@ -52,28 +49,88 @@ export const deploymentStateSchema = z.strictObject({ export async function readState(config: DeploymentConfig): Promise { const state = deploymentStateSchema.parse(JSON.parse(await readFile(statePath(config), 'utf8'))); if (state.environmentKey !== environmentKey(config)) throw new Error('Deployment state belongs to a different environment'); - if (config.profile === 'role-based-data' && state.applicationHome !== await realpath(deploymentProjectRoot())) { - throw new Error('Role-based deployment state belongs to a different application checkout; discovery or copied state is not reuse approval'); + if (state.applicationHome !== await realpath(deploymentProjectRoot())) { + throw new Error('Deployment state belongs to a different application checkout; discovery or copied state is not reuse approval'); } return state; } export async function deploymentConfigHash(config: DeploymentConfig): Promise { - const hash = createHash('sha256').update(JSON.stringify(config)); - if (config.profile === 'role-based-data') { - const home = await realpath(deploymentProjectRoot()); - hash.update(home).update(await roleBasedSchemaFingerprint(home)); - } - return hash.digest('hex'); + const home = await realpath(deploymentProjectRoot()); + return createHash('sha256').update(JSON.stringify(deploymentSchema.parse(config))).update(home) + .update(await roleBasedSchemaFingerprint(home)).digest('hex'); } export function armParameters(config: DeploymentConfig, deployGateway: boolean) { - const { subscriptionId: _subscription, resourceGroup: _group, readinessPath: _readiness, ...parameters } = config; - return { parameters: Object.fromEntries(Object.entries({ ...parameters, - ...(config.profile === 'role-based-data' ? { readinessPath: config.readinessPath } : {}), deployGateway }) - .filter(([, value]) => value !== undefined).map(([key, value]) => [key, { value }])) }; + const { subscriptionId: _subscription, resourceGroup: _group, profile: _profile, functionsImage: _legacy, + imageSource: _source, ...parameters } = config; + return { parameters: Object.fromEntries(Object.entries({ ...parameters, deployGateway }) + .map(([key, value]) => [key, { value }])) }; +} + +export function rbacParameters(config: DeploymentConfig, outputs: DeploymentOutputs) { + return { parameters: { + name: { value: config.name }, + environment: { value: config.environment }, + registryServer: { value: config.registryServer }, + gatewayPrincipalId: { value: outputs.gatewayPrincipalId }, + dabPrincipalId: { value: outputs.dabPrincipalId }, + } }; +} + +const acrPullRoleId = '7f951dda-4ed3-4680-a7ca-43fe172d538d'; + +export interface ExpectedRbacAssignment { + label: string; + principalId: string; + roleDefinitionId: string; + scope: string; +} + +export function expectedRbacAssignments( + config: DeploymentConfig, outputs: DeploymentOutputs, +): ExpectedRbacAssignment[] { + const root = `/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}/providers`; + const role = (id: string) => `/subscriptions/${config.subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/${id}`; + const registry = `${root}/Microsoft.ContainerRegistry/registries/${config.registryServer.split('.')[0]}`; + return [ + { label: 'ACR pull for gateway', principalId: outputs.gatewayPrincipalId, + roleDefinitionId: role(acrPullRoleId), scope: registry }, + { label: 'ACR pull for data', principalId: outputs.dabPrincipalId, + roleDefinitionId: role(acrPullRoleId), scope: registry }, + ]; +} + +export async function verifyRbacAssignments( + config: DeploymentConfig, outputs: DeploymentOutputs, run: Run, +): Promise { + const expected = expectedRbacAssignments(config, outputs); + const actual = new Map[]>(); + for (const scope of new Set(expected.map(item => item.scope))) { + const rows = z.array(liveAssignmentSchema).parse(JSON.parse(await run('az', [ + 'role', 'assignment', 'list', '--subscription', config.subscriptionId, + '--scope', scope, '--include-inherited', '-o', 'json', + ]))); + actual.set(scope.toLowerCase(), rows); + } + const same = (left: string, right: string) => left.toLowerCase() === right.toLowerCase(); + const missing = expected.filter(item => !(actual.get(item.scope.toLowerCase()) ?? []).some(row => + same(row.principalId, item.principalId) && + same(row.roleDefinitionId, item.roleDefinitionId) && + same(row.scope, item.scope))); + if (missing.length) { + throw new Error(`RBAC handoff is incomplete:\n${missing.map(item => + `- ${item.label}: principal ${item.principalId}, role ${item.roleDefinitionId}, scope ${item.scope}`).join('\n')}\n` + + 'An authorized administrator must run: npm run azure -- rbac-deploy '); + } } +const liveAssignmentSchema = z.object({ + principalId: z.string().uuid(), + roleDefinitionId: z.string().min(1), + scope: z.string().min(1), +}).passthrough(); + export function parseOutputs(text: string): DeploymentOutputs { const envelope = z.record(z.string(), z.object({ value: z.unknown() })).parse(JSON.parse(text)); return outputSchema.parse(Object.fromEntries(Object.entries(envelope).map(([key, result]) => [key, result.value]))); @@ -183,43 +240,35 @@ export async function preflight(config: DeploymentConfig, run: Run, checkImages allowedMemberTypes: z.array(z.string()).optional() })), }).parse(JSON.parse(await run('az', ['ad', 'app', 'show', '--id', config.apiClientId, '-o', 'json']))); if (!identity.api.oauth2PermissionScopes.some(scope => scope.value === 'access_as_user' && scope.isEnabled) || - !identity.appRoles.some(role => config.profile === 'role-based-data' ? - role.value === config.requiredRole && role.isEnabled && role.allowedMemberTypes?.includes('User') : - role.id === functionRoleId && role.value === 'Function.Invoke' && role.isEnabled)) { - throw new Error(config.profile === 'role-based-data' ? - 'App registration must expose access_as_user and the enabled human application role' : - 'App registration must expose access_as_user and the documented Function.Invoke role'); + !identity.appRoles.some(role => role.value === config.requiredRole && role.isEnabled && role.allowedMemberTypes?.includes('User'))) { + throw new Error('App registration must expose access_as_user and the enabled human application role'); } await run('az', ['acr', 'show', '--name', config.registryServer.split('.')[0]!, '--resource-group', config.resourceGroup, '--subscription', config.subscriptionId, '-o', 'none']); if (checkImages) { - for (const field of deploymentImageFields(config)) await imageDigest(config[field], config, run); + for (const field of imageFields) await imageDigest(config[field], config, run); } - if (config.profile === 'role-based-data') validateRoleBasedCloudDab(JSON.parse(await readFile(deploymentProjectPath('dab', 'dab-config.json'), 'utf8')), { - profile: 'role-based-data', requiredRole: config.requiredRole!, readinessPath: config.readinessPath!, - }); - const providers = ['Microsoft.App', 'Microsoft.Sql', 'Microsoft.Network', 'Microsoft.ManagedIdentity', - 'Microsoft.OperationalInsights', 'Microsoft.Authorization']; - if (config.profile !== 'role-based-data') providers.push('Microsoft.Storage', 'Microsoft.Web', 'Microsoft.KeyVault', 'Microsoft.Insights'); - for (const namespace of providers) { + validateRoleBasedCloudDab(JSON.parse(await readFile(deploymentProjectPath('dab', 'dab-config.json'), 'utf8')), config); + for (const namespace of ['Microsoft.App', 'Microsoft.Sql', 'Microsoft.Network', 'Microsoft.ManagedIdentity', + 'Microsoft.OperationalInsights', 'Microsoft.Authorization']) { const status = (await run('az', ['provider', 'show', '--namespace', namespace, '--subscription', config.subscriptionId, '--query', 'registrationState', '-o', 'tsv'])).trim(); if (status !== 'Registered') throw new Error(`Register resource provider ${namespace} before deploying`); } } +export async function readSavedState(config: DeploymentConfig): Promise { + try { return await readState(config); } + catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined; + throw error; + } +} + export async function provision(config: DeploymentConfig, run: Run): Promise { const configHash = await deploymentConfigHash(config); await preflight(config, run); - if (config.profile === 'role-based-data') { - let previous: DeploymentState | undefined; - try { previous = await readState(config); } - catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; } - if (previous) { - if (previous.configHash !== configHash) { - throw new Error('Role-based configuration or SQL/DAB source changed; provisioning cannot overwrite the saved deployment subject'); - } - return previous; - } - } + const previous = await readSavedState(config); + if (previous?.configHash === configHash) return previous; + // A changed configuration or SQL/DAB source re-applies the idempotent infrastructure stage; deploy then rolls it out. const outputs = parseOutputs(await run('az', [ 'deployment', 'group', 'create', '--subscription', config.subscriptionId, '--resource-group', config.resourceGroup, '--name', `${config.name}-${config.environment}-infrastructure`, '--template-file', deploymentTemplatePath(), @@ -230,7 +279,7 @@ export async function provision(config: DeploymentConfig, run: Run): Promise { + const path = `${statePath(config)}.rbac.parameters.json`; + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(rbacParameters(config, outputs), null, 2)); + return path; +} + +export const grantsHandoffPath = () => deploymentProjectPath('dist', 'sql', 'role-based-grants.sql'); + +/** Writes the exact DAB procedure grants SQL Apps applies, so a maintained deployment route can apply the same set. */ +export async function writeGrantsHandoff(config: DeploymentConfig): Promise { + const grants = roleBasedProcedureGrants( + JSON.parse(await readFile(deploymentProjectPath('dab', 'dab-config.json'), 'utf8')), config, 'sql_apps_dab'); + await mkdir(dirname(grantsHandoffPath()), { recursive: true }); + await writeFile(grantsHandoffPath(), '-- Generated by SQL Apps from dab/dab-config.json; do not edit.\n' + + '-- Requires the database user [sql_apps_dab] for the DAB managed identity (see sql/grant-runtime.sql).\n' + + `${grants}\n`); + return grants; +} + export async function publishSchema(config: DeploymentConfig, outputs: DeploymentOutputs, run: Run) { - const roleBasedGrants = config.profile === 'role-based-data' ? roleBasedProcedureGrants( - JSON.parse(await readFile(deploymentProjectPath('dab', 'dab-config.json'), 'utf8')), - { profile: 'role-based-data', requiredRole: config.requiredRole!, readinessPath: config.readinessPath! }, 'sql_apps_dab', - ) : ''; + const grants = await writeGrantsHandoff(config); await run('dotnet', ['build', deploymentProjectPath('sql', 'database.sqlproj'), '--output', deploymentProjectPath('dist', 'sql')]); - const { accessToken } = z.object({ accessToken: z.string().min(1) }).parse(JSON.parse(await run('az', [ - 'account', 'get-access-token', '--subscription', config.subscriptionId, '--resource', 'https://database.windows.net/', '-o', 'json', - ]))); + // SqlPackage signs in through the Azure CLI itself, so no bearer token appears in the process list. await run('dotnet', ['tool', 'run', 'sqlpackage', '--', '/Action:Publish', `/SourceFile:${deploymentProjectPath('dist', 'sql', 'database.dacpac')}`, - `/TargetConnectionString:Server=tcp:${outputs.sqlServer},1433;Database=app;Encrypt=True;TrustServerCertificate=False;`, - `/AccessToken:${accessToken}`, + `/TargetConnectionString:Server=tcp:${outputs.sqlServer},1433;Database=app;Encrypt=True;TrustServerCertificate=False;` + + 'Authentication=Active Directory Default;', `/v:DabPrincipalId=${outputs.dabPrincipalId}`, - ...(config.profile === 'role-based-data' ? [`/v:RoleBasedProcedureGrants=${roleBasedGrants}`] : []), + `/v:RoleBasedProcedureGrants=${grants}`, '/p:BlockOnPossibleDataLoss=True', '/p:DropObjectsNotInSource=False', - ]); + ], { env: { AZURE_TOKEN_CREDENTIALS: 'AzureCliCredential', AZURE_TENANT_ID: config.tenantId } }); } export async function deploy( @@ -266,79 +330,31 @@ export async function deploy( ): Promise { await preflight(config, run); const configHash = await deploymentConfigHash(config); + if (config.imageSource !== await roleBasedSchemaFingerprint(await realpath(deploymentProjectRoot()))) { + throw new Error('Images were built from different SQL/DAB source (DAB bakes dab-config.json into its image); run artifacts, then deploy'); + } const deployResources = async (enabled: boolean) => parseOutputs(await run('az', [ 'deployment', 'group', 'create', '--subscription', config.subscriptionId, '--resource-group', config.resourceGroup, '--name', `${config.name}-${config.environment}-${enabled ? 'runtime' : 'infrastructure'}`, '--template-file', deploymentTemplatePath(), '--parameters', `@${await parametersFile(config, enabled)}`, '--query', 'properties.outputs', '-o', 'json', ])); - let previous: DeploymentState | undefined; - if (config.profile === 'role-based-data') { - try { previous = await readState(config); } - catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; } - if (previous && previous.configHash !== configHash) throw new Error('Role-based deployment configuration or SQL/DAB source changed; review the prior deployment before resuming'); - } + const saved = await readSavedState(config); + // Same configuration and source: resume from the saved stage. Anything changed: roll every stage forward again. + const previous = saved?.configHash === configHash ? saved : undefined; let state: DeploymentState = previous ?? { environmentKey: environmentKey(config), configHash, stage: 'infrastructure', - outputs: await deployResources(false), ...(config.profile === 'role-based-data' ? { applicationHome: await realpath('.') } : {}) }; + outputs: await deployResources(false), applicationHome: await realpath(deploymentProjectRoot()) }; await persist(state); + await verifyRbacAssignments(config, state.outputs, run); if (!previous || previous.stage === 'infrastructure') await publishSchema(config, state.outputs, run); - if (config.profile === 'role-based-data') { - if (!previous || ['infrastructure', 'schema'].includes(previous.stage)) { - state = { ...state, stage: 'schema' }; - await persist(state); - state = { ...state, stage: 'runtime', outputs: await deployResources(true) }; - await persist(state); - } - await configureRedirect(config, state.outputs.gatewayUrl, run); - return await waitForReadiness(config, state, fetcher, persist); - } - state = { ...state, stage: 'schema' }; - await persist(state); - const apiServicePrincipal = z.string().uuid().parse( - (await run('az', ['ad', 'sp', 'show', '--id', config.apiClientId, '--query', 'id', '-o', 'tsv'])).trim(), - ); - const assignmentsUrl = `https://graph.microsoft.com/v1.0/servicePrincipals/${state.outputs.gatewayPrincipalId}/appRoleAssignments`; - let pageUrl: string | undefined = assignmentsUrl; - let hasFunctionRole = false; - const visited = new Set(); - while (pageUrl) { - if ((pageUrl !== assignmentsUrl && !pageUrl.startsWith(`${assignmentsUrl}?`)) || visited.has(pageUrl)) { - throw new Error('Invalid or repeated Graph role-assignment continuation URL'); - } - visited.add(pageUrl); - const page = z.object({ - value: z.array(z.object({ appRoleId: z.string(), resourceId: z.string() })), - '@odata.nextLink': z.url().optional(), - }).parse(JSON.parse(await run('az', ['rest', '--method', 'GET', '--url', pageUrl, '-o', 'json']))); - if (page.value.some(role => role.appRoleId === functionRoleId && role.resourceId === apiServicePrincipal)) { - hasFunctionRole = true; - break; - } - pageUrl = page['@odata.nextLink']; - } - if (!hasFunctionRole) { - const path = `${statePath(config)}.role.json`; - await writeFile(path, JSON.stringify({ principalId: state.outputs.gatewayPrincipalId, resourceId: apiServicePrincipal, appRoleId: functionRoleId })); - await run('az', ['rest', '--method', 'POST', '--url', - `https://graph.microsoft.com/v1.0/servicePrincipals/${state.outputs.gatewayPrincipalId}/appRoleAssignments`, '--body', `@${path}`, '-o', 'none']); + if (!previous || ['infrastructure', 'schema'].includes(previous.stage)) { + state = { ...state, stage: 'schema' }; + await persist(state); + state = { ...state, stage: 'runtime', outputs: await deployResources(true) }; + await persist(state); } - state = { ...state, stage: 'runtime', outputs: await deployResources(true) }; - await persist(state); await configureRedirect(config, state.outputs.gatewayUrl, run); - for (let attempt = 0; attempt < 30; attempt++) { - try { - const response = await fetcher(new URL('/health/ready', state.outputs.gatewayUrl), { signal: AbortSignal.timeout(5_000) }); - if (response.ok) { - state = { ...state, stage: 'ready' }; - await persist(state); - return state; - } - } catch (error) { - console.error(`Readiness attempt ${attempt + 1} failed: ${error instanceof Error ? error.name : 'UnknownError'}`); - } - if (attempt < 29) await new Promise(done => setTimeout(done, 10_000)); - } - throw new Error('Runtime deployed but readiness failed. Deployment state remains at runtime; inspect Container Apps logs.'); + return await waitForReadiness(config, state, fetcher, persist); } export async function waitForReadiness( diff --git a/src/file-jobs.ts b/src/file-jobs.ts deleted file mode 100644 index b55930b..0000000 --- a/src/file-jobs.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from 'zod'; - -export const jobMessageSchema = z.strictObject({ - id: z.string().uuid(), oid: z.string().uuid(), tenantId: z.string().uuid(), key: z.string().min(1).max(256), - trace: z.object({ traceId: z.string().regex(/^[a-f0-9]{32}$/), spanId: z.string().regex(/^[a-f0-9]{16}$/) }).optional(), -}); -export type JobMessage = z.infer; -export const fileJobSchema = z.object({ - id: z.string().uuid().transform(value => value.toLowerCase()), - filename: z.string().min(1).refine(value => value.length <= 128, 'Filename must be at most 128 UTF-16 code units'), - status: z.enum(['queued', 'processing', 'completed', 'failed']), - sha256: z.string().regex(/^[a-f0-9]{64}$/).nullable(), byte_count: z.number().int().nonnegative().nullable(), - line_count: z.number().int().nonnegative().nullable(), - error: z.string().nullable(), - created_at: z.string().optional(), - updated_at: z.string().optional(), - parent_job_id: z.string().uuid().nullable().optional(), -}); -export type FileJob = z.infer; diff --git a/src/file-lock.mjs b/src/file-lock.mjs index a3f87ce..63f765d 100644 --- a/src/file-lock.mjs +++ b/src/file-lock.mjs @@ -74,7 +74,11 @@ async function clearStale(lock) { try { if (!await stale(owner, ownerInfo, entries[0].slice(0, -5))) return false; await unlink(owner); - } catch (error) { if (!hasCode(error, 'ENOENT')) throw error; } + } catch (error) { + // Windows denies access to an owner file pending deletion during release; an unreadable owner is never stale. + if (hasCode(error, 'EPERM', 'EBUSY')) return false; + if (!hasCode(error, 'ENOENT')) throw error; + } } else if (Date.now() - info.mtimeMs < 30_000) return false; try { await rmdir(lock); return true; } catch (error) { diff --git a/src/gateway.ts b/src/gateway.ts index 84aa9e4..2ae0f1c 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -4,62 +4,32 @@ import { resolve } from 'node:path'; import { bearerToken, type VerifyUser, type UserIdentity } from './auth.js'; import { accessFor } from './access.js'; import type { RuntimeConfig } from './config.js'; -import { objectKey, type FileStore } from './storage.js'; import type { BrowserConfig } from './browser-config.js'; -import type { FileJob } from './file-jobs.js'; -import { z } from 'zod'; -import type { LocalTracing } from './local-tracing.js'; -import type { TraceParent } from './trace-contract.js'; -import type { Span } from '@opentelemetry/api'; import { DependencyReadinessError } from './readiness.js'; -export interface FileProcessing { - list(identity: UserIdentity): Promise; - create(identity: UserIdentity, name: string, parent?: TraceParent): Promise; - retry?(identity: UserIdentity, id: string, parent?: TraceParent): Promise; - delete(identity: UserIdentity, id: string): Promise; -} - export interface GatewayDependencies { verifyUser: VerifyUser; - files: FileStore; fetch: typeof fetch; - functionToken: () => Promise; browserConfig?: BrowserConfig; requestGuard?: (request: FastifyRequest, reply: FastifyReply) => Promise; - processing?: FileProcessing; - telemetry?: LocalTracing; ready?: () => Promise; } interface AuthenticatedRequest extends FastifyRequest { identity: UserIdentity | null; accessToken: string | null; - traceSpan: Span | null; } export async function createGateway(config: RuntimeConfig, dependencies: GatewayDependencies) { - const requiredRole = config.profile === 'role-based-data' ? config.requiredRole : undefined; - if (config.profile === 'role-based-data' && !requiredRole) { - throw new Error('Role-based-data requires a configured application role'); - } + const { requiredRole } = config; const app = Fastify({ - bodyLimit: 4 * 1024 * 1024, + bodyLimit: 1024 * 1024, logger: { redact: ['req.headers.authorization', 'req.headers.cookie'], level: 'info' }, requestTimeout: 35_000, }); app.decorateRequest('identity', null); app.decorateRequest('accessToken', null); - app.decorateRequest('traceSpan', null); - app.addHook('onResponse', async (request, reply) => { - const span = (request as AuthenticatedRequest).traceSpan; - if (span && dependencies.telemetry) await dependencies.telemetry.end(span, reply.statusCode >= 400); - }); - if (dependencies.telemetry) app.addHook('onClose', () => dependencies.telemetry!.shutdown()); if (dependencies.requestGuard) app.addHook('onRequest', dependencies.requestGuard); - app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_request, body, done) => { - done(null, body); - }); app.setErrorHandler((error, request, reply) => { const status = typeof error === 'object' && error !== null && 'statusCode' in error && typeof error.statusCode === 'number' ? error.statusCode : 500; @@ -98,7 +68,7 @@ export async function createGateway(config: RuntimeConfig, dependencies: Gateway tenantId: config.tenantId, clientId: config.apiClientId, scope: `api://${config.apiClientId}/access_as_user`, - capabilities: { files: config.profile !== 'role-based-data', functions: config.profile !== 'role-based-data' }, + readinessPath: config.readinessPath, }; }); await app.register(async protectedRoutes => { @@ -118,10 +88,6 @@ export async function createGateway(config: RuntimeConfig, dependencies: Gateway if (request.routeOptions.url !== '/auth/me' && !accessFor(identity, requiredRole).allowed) { return reply.code(403).send({ error: 'Authorized role required' }); } - if (dependencies.telemetry && request.routeOptions.url !== '/diagnostics/traces' && - !(request.method === 'GET' && request.routeOptions.url === '/jobs')) { - authenticated.traceSpan = dependencies.telemetry.begin(identity, `${request.method} ${request.routeOptions.url}`); - } }); protectedRoutes.addHook('onSend', async (_request, reply) => { reply.header('cache-control', 'no-store'); @@ -136,51 +102,6 @@ export async function createGateway(config: RuntimeConfig, dependencies: Gateway handler: proxyData, }); protectedRoutes.post('/graphql', proxyData); - if (config.profile !== 'role-based-data') { - protectedRoutes.get('/storage', async (request, reply) => { - if (!dependencies.files.list) return reply.code(503).send({ error: 'File listing unavailable' }); - const query = z.strictObject({ continuationToken: z.string().min(1).max(2048).optional() }).safeParse(request.query); - if (!query.success) return reply.code(400).send({ error: 'Invalid file continuation token' }); - const identity = (request as AuthenticatedRequest).identity!; - return dependencies.files.list(`${identity.tenantId}/${identity.oid}/`, { - ...(query.data.continuationToken ? { continuationToken: query.data.continuationToken } : {}), - maxPageSize: 100, - }); - }); - protectedRoutes.get('/jobs', async (request, reply) => { - if (!dependencies.processing) return reply.code(503).send({ error: 'File processing unavailable' }); - return { jobs: await dependencies.processing.list((request as AuthenticatedRequest).identity!) }; - }); - protectedRoutes.get('/diagnostics/traces', async (request, reply) => { - if (!dependencies.telemetry) return reply.code(503).send({ error: 'Local tracing unavailable' }); - return { traces: await dependencies.telemetry.list((request as AuthenticatedRequest).identity!) }; - }); - protectedRoutes.post('/jobs', async (request, reply) => { - if (!dependencies.processing) return reply.code(503).send({ error: 'File processing unavailable' }); - const body = z.strictObject({ name: z.string() }).safeParse(request.body); - if (!body.success) return reply.code(400).send({ error: 'A file name is required; job fields are server managed' }); - const identity = (request as AuthenticatedRequest).identity!; - try { objectKey(identity.tenantId, identity.oid, body.data.name); } - catch { return reply.code(400).send({ error: 'Invalid file name' }); } - const id = await dependencies.processing.create(identity, body.data.name, (request as AuthenticatedRequest).traceSpan?.spanContext()); - return reply.code(202).send({ id }); - }); - protectedRoutes.post('/jobs/:id/retry', async (request, reply) => { - if (!dependencies.processing?.retry) return reply.code(503).send({ error: 'Job retry unavailable' }); - const params = z.object({ id: z.string().uuid() }).safeParse(request.params); - if (!params.success || request.body !== undefined) return reply.code(400).send({ error: 'Retry requires a job id and no body' }); - const authenticated = request as AuthenticatedRequest; - const id = await dependencies.processing.retry(authenticated.identity!, params.data.id, authenticated.traceSpan?.spanContext()); - return reply.code(202).send({ id }); - }); - protectedRoutes.delete('/jobs/:id', async (request, reply) => { - if (!dependencies.processing) return reply.code(503).send({ error: 'File processing unavailable' }); - const params = z.object({ id: z.string().uuid() }).safeParse(request.params); - if (!params.success) return reply.code(400).send({ error: 'Invalid job id' }); - return reply.code(await dependencies.processing.delete((request as AuthenticatedRequest).identity!, params.data.id) ? 204 : 404).send(); - }); - } - async function proxyData(request: FastifyRequest, reply: import('fastify').FastifyReply) { const url = new URL(config.dabUrl); const incoming = new URL(request.raw.url ?? '/', 'http://gateway.invalid'); @@ -188,7 +109,7 @@ export async function createGateway(config: RuntimeConfig, dependencies: Gateway url.search = incoming.search; const headers = new Headers({ authorization: `Bearer ${(request as AuthenticatedRequest).accessToken}` }); if (request.headers['if-match'] === '*') headers.set('if-match', '*'); - if (config.profile === 'role-based-data') headers.set('x-ms-api-role', config.requiredRole!); + headers.set('x-ms-api-role', requiredRole); if (request.body !== undefined) headers.set('content-type', 'application/json'); const response = await dependencies.fetch(url, { method: request.method, @@ -201,48 +122,6 @@ export async function createGateway(config: RuntimeConfig, dependencies: Gateway reply.header('content-type', response.headers.get('content-type') ?? 'application/json'); return reply.send(response.body ?? undefined); } - - if (config.profile !== 'role-based-data') { - protectedRoutes.route({ - method: ['PUT', 'GET', 'DELETE'], - url: '/storage/:name', - handler: async (request, reply) => { - const params = request.params as { name: string }; - const identity = (request as AuthenticatedRequest).identity!; - let key: string; - try { - key = objectKey(identity.tenantId, identity.oid, params.name); - } catch { - return reply.code(400).send({ error: 'Invalid file name' }); - } - if (request.method === 'PUT') { - if (!Buffer.isBuffer(request.body)) return reply.code(415).send({ error: 'Use application/octet-stream' }); - await dependencies.files.put(key, request.body); - return reply.code(204).send(); - } - if (request.method === 'DELETE') { - return reply.code(await dependencies.files.delete(key) ? 204 : 404).send(); - } - const file = await dependencies.files.get(key); - if (!file) return reply.code(404).send({ error: 'File not found' }); - reply.header('content-disposition', 'attachment'); - return reply.type(file.contentType).send(file.bytes); - }, - }); - protectedRoutes.post('/functions/echo', async (request, reply) => { - const identity = (request as AuthenticatedRequest).identity!; - const token = await dependencies.functionToken(); - const response = await dependencies.fetch(new URL('/api/echo', config.functionsUrl), { - method: 'POST', - headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, - body: JSON.stringify({ user: identity, input: request.body }), - redirect: 'error', - signal: AbortSignal.timeout(30_000), - }); - return reply.code(response.status).type('application/json') - .send(response.body ?? undefined); - }); - } }); await app.register(fastifyStatic, { root: resolve(config.publicDirectory), index: ['index.html'] }); return app; diff --git a/src/hosting-comparison.ts b/src/hosting-comparison.ts deleted file mode 100644 index 32c3f79..0000000 --- a/src/hosting-comparison.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { z } from 'zod'; - -const workload = z.strictObject({ - monthlyRequests: z.number().int().finite().nonnegative(), - monthlyDataTransferGb: z.number().finite().nonnegative(), - activeBackendHours: z.number().finite().nonnegative(), -}); - -const httpsSource = z.string().url().refine(value => new URL(value).protocol === 'https:', - 'Cost evidence sources must use HTTPS.'); - -const hostingCost = z.strictObject({ - hosting: z.enum(['container-apps', 'static-web-apps']), - monthlyCost: z.number().finite().positive(), - source: httpsSource, -}); - -const comparisonSchema = z.strictObject({ - version: z.literal(1), - profile: z.literal('public-demo'), - region: z.string().regex(/^[a-z0-9-]+$/), - currency: z.string().regex(/^[A-Z]{3}$/), - checkedOn: z.iso.date(), - workload, - current: hostingCost.extend({ hosting: z.literal('container-apps') }), - candidate: hostingCost.extend({ hosting: z.literal('static-web-apps') }), - liveAcceptance: z.boolean(), -}); - -export interface HostingComparisonResult { - deltaMonthly: number; - deltaPercent: number | null; - recommendedDefault: 'container-apps' | 'static-web-apps'; - reason: - | 'candidate-costs-more' - | 'candidate-not-live-accepted' - | 'candidate-costs-no-more-and-is-live-accepted'; - defaultChangeAuthorized: false; -} - -export function compareHosting(input: unknown, now = new Date()): HostingComparisonResult { - const config = comparisonSchema.parse(input); - const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); - const checkedOn = Date.parse(`${config.checkedOn}T00:00:00Z`); - const ageDays = (today - checkedOn) / 86_400_000; - if (ageDays < 0) throw new Error('Hosting cost evidence cannot be dated in the future.'); - if (ageDays > 30) throw new Error('Hosting cost evidence must be no more than 30 days old.'); - - const deltaMonthly = config.candidate.monthlyCost - config.current.monthlyCost; - const candidateEligible = config.liveAcceptance && - config.candidate.monthlyCost <= config.current.monthlyCost; - const reason = config.candidate.monthlyCost > config.current.monthlyCost ? - 'candidate-costs-more' : - !config.liveAcceptance ? 'candidate-not-live-accepted' : - 'candidate-costs-no-more-and-is-live-accepted'; - - return { - deltaMonthly, - deltaPercent: config.current.monthlyCost === 0 ? null : - deltaMonthly / config.current.monthlyCost * 100, - recommendedDefault: candidateEligible ? 'static-web-apps' : 'container-apps', - reason, - defaultChangeAuthorized: false, - }; -} diff --git a/src/local-app-smoke.ts b/src/local-app-smoke.ts index 3f60774..bc3a0d8 100644 --- a/src/local-app-smoke.ts +++ b/src/local-app-smoke.ts @@ -1,13 +1,13 @@ import { z } from 'zod'; -import { SqlAppsClient } from './client.js'; +import { ApiError, SqlAppsClient } from './client.js'; import { developmentUsers, localAppOrigin } from './local-app.js'; -import { fileJobSchema } from './file-jobs.js'; +import type { RoleBasedProfile } from './role-based-profile.js'; -export async function testLocalApp(fetcher: typeof fetch = fetch): Promise { +export async function testLocalApp(profile: RoleBasedProfile, fetcher: typeof fetch = fetch): Promise { const tokens: string[] = []; const failures: unknown[] = []; try { - for (const user of developmentUsers) { + for (const [index, user] of developmentUsers.entries()) { const response = await fetcher(`${localAppOrigin}/local/session`, { method: 'POST', headers: { origin: localAppOrigin, 'content-type': 'application/json' }, body: JSON.stringify({ user: user.id }), signal: AbortSignal.timeout(10_000), @@ -18,10 +18,13 @@ export async function testLocalApp(fetcher: typeof fetch = fetch): Promise const client = new SqlAppsClient({ baseUrl: localAppOrigin, getAccessToken: async () => token, fetch: fetcher }); const identity = z.object({ oid: z.string() }).parse(await (await client.request('/auth/me')).json()); if (identity.oid !== user.id) throw new Error('Gateway assigned the wrong development identity'); - z.object({ jobs: z.array(fileJobSchema.strict()) }) - .parse(await (await client.request('/jobs')).json()); + const status = await client.readiness(profile.readinessPath).then(() => 200, + (error: unknown) => { if (error instanceof ApiError) return error.status; throw error; }); + const expected = index === 0 ? 200 : 403; + if (status !== expected) throw new Error(`${profile.readinessPath} returned ${status} for ${user.name}; expected ${expected}`); } if ((await fetcher(`${localAppOrigin}/auth/me`)).status !== 401) throw new Error('Gateway accepted anonymous access'); + if ((await fetcher(`${localAppOrigin}${profile.readinessPath}`)).status !== 401) throw new Error('Gateway served data anonymously'); } catch (error) { failures.push(error); } finally { for (const token of tokens) { diff --git a/src/local-app.ts b/src/local-app.ts index cafe7ef..2efa17b 100644 --- a/src/local-app.ts +++ b/src/local-app.ts @@ -7,14 +7,11 @@ import { readLocalAuthentication, type LocalAuthentication } from './local-auth. import { authRedirectPath } from './browser-config.js'; import { createGateway } from './gateway.js'; import { localOrigin, localPrincipal } from './local.js'; -import { localFunctionsOrigin, localServiceAdapters } from './local-services.js'; import { runtimeFor } from './workspace.mjs'; import type { RoleBasedProfile } from './role-based-profile.js'; import { probeReadiness } from './readiness.js'; import { projectPath } from './runtime-roots.js'; -type LocalAdapters = Awaited>; - export const localAppOrigin = runtimeFor().origins.app; const tenantId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; export const developmentUsers = [ @@ -22,10 +19,9 @@ export const developmentUsers = [ { id: '22222222-2222-4222-8222-222222222222', name: 'Development Bob' }, ]; -export async function createLocalApp(fetcher: typeof fetch = fetch, services?: LocalAdapters, authorized?: RoleBasedProfile, +export async function createLocalApp(fetcher: typeof fetch, authorized: RoleBasedProfile, container = runtimeFor().defaultSql, authentication: LocalAuthentication = readLocalAuthentication(), entraKey?: JWTVerifyGetKey) { if (process.env.NODE_ENV === 'production') throw new Error('Local development server is disabled in production'); - if (authorized && services) throw new Error('Role-based-data excludes storage and Functions adapters'); const provenance = z.object({ version: z.literal(2), home: z.literal(runtimeFor().home), sourceFingerprint: z.string().regex(/^[a-f0-9]{64}$/), toolingFingerprint: z.string().regex(/^[a-f0-9]{64}$/) }) @@ -38,59 +34,40 @@ export async function createLocalApp(fetcher: typeof fetch = fetch, services?: L sessions.delete(token); throw new Error('Local session expired; select a development user again'); } - return { oid: session.oid, tenantId, ...(authorized ? { - roles: session.oid === developmentUsers[0]!.id ? [authorized.requiredRole] : [], - } : {}) }; + return { oid: session.oid, tenantId, + roles: session.oid === developmentUsers[0]!.id ? [authorized.requiredRole] : [] }; }; const identity: VerifyUser = authentication.mode === 'entra' ? entraVerifier(authentication.tenantId, authentication.clientId, entraKey) : previewIdentity; const gatewayTenant = authentication.mode === 'entra' ? authentication.tenantId : tenantId; const gatewayClient = authentication.mode === 'entra' ? authentication.clientId : tenantId; - const unavailable = async (): Promise => { - throw Object.assign(new Error('This service is not configured in the local SQL development stack'), { statusCode: 503 }); - }; const ready = async (): Promise => { const signal = AbortSignal.timeout(4_000); await probeReadiness('data', async () => { - const role = authorized?.requiredRole ?? 'authenticated'; - const response = await fetcher(new URL(authorized?.readinessPath ?? '/api/FileJob?$first=1', localOrigin), { + const role = authorized.requiredRole; + const response = await fetcher(new URL(authorized.readinessPath, localOrigin), { headers: { 'x-ms-client-principal': localPrincipal(developmentUsers[0]!.id, 'access_as_user', role), 'x-ms-api-role': role }, signal, redirect: 'error', }); if (!response.ok) throw new Error(`Authorized data readiness returned HTTP ${response.status}`); - if (authorized) await response.body?.cancel(); - else z.object({ value: z.array(z.object({ id: z.string().uuid() })).max(1) }).parse(await response.json()); + await response.body?.cancel(); }); - if (services) await services.ready(fetcher, signal); - }; - const capabilities = { - files: Boolean(services), functions: Boolean(services), ...(services ? { - processing: true, jobSubmission: services.settings.processing, - jobRetry: services.settings.processing && services.settings.jobRetry, - automaticProgress: services.settings.automaticProgress, - tracing: services.settings.tracing, progressIntervalMs: services.settings.progressIntervalMs, - } : {}), }; - const users = authorized ? developmentUsers.map((user, index) => ({ + const users = developmentUsers.map((user, index) => ({ ...user, name: `${user.name} (${index === 0 ? 'simulated user with required role' : 'simulated user without required role'})`, - })) : developmentUsers; + })); const app = await createGateway({ - ...authorized, + requiredRole: authorized.requiredRole, readinessPath: authorized.readinessPath, tenantId: gatewayTenant, apiClientId: gatewayClient, dabUrl: localOrigin, - blobAccountUrl: 'https://unconfigured.invalid', blobContainer: 'files', - functionsUrl: services ? localFunctionsOrigin : 'https://unconfigured.invalid', publicDirectory: projectPath('public'), }, { verifyUser: identity, - files: services?.files ?? { put: unavailable, get: unavailable, delete: unavailable }, - functionToken: services?.functionToken ?? unavailable, - ...(services ? { processing: services.processing, ...(services.telemetry ? { telemetry: services.telemetry } : {}) } : {}), ready, browserConfig: authentication.mode === 'entra' ? { mode: 'entra', tenantId: authentication.tenantId, clientId: authentication.clientId, - scope: `api://${authentication.clientId}/access_as_user`, localBackend: true, capabilities, - } : { mode: 'local', capabilities }, + scope: `api://${authentication.clientId}/access_as_user`, localBackend: true, readinessPath: authorized.readinessPath, + } : { mode: 'local', readinessPath: authorized.readinessPath }, requestGuard: async (request, reply) => { if (request.headers.host !== new URL(localAppOrigin).host) { return reply.code(403).send({ error: 'Use the loopback development origin' }); @@ -101,7 +78,6 @@ export async function createLocalApp(fetcher: typeof fetch = fetch, services?: L }, fetch: async (url, options) => { const target = new URL(String(url)); - if (services && target.origin === localFunctionsOrigin && target.pathname === '/api/echo') return fetcher(target, options); if (target.origin !== localOrigin) throw new Error('Local adapter only connects to the loopback data service'); if (target.pathname === '/health') return fetcher(target, options); const headers = new Headers(options?.headers); @@ -110,8 +86,8 @@ export async function createLocalApp(fetcher: typeof fetch = fetch, services?: L const user = await identity(token); headers.delete('authorization'); headers.delete('x-ms-api-role'); - headers.set('x-ms-client-principal', localPrincipal(user.oid, 'access_as_user', authorized?.requiredRole ?? 'authenticated')); - if (authorized) headers.set('x-ms-api-role', authorized.requiredRole); + headers.set('x-ms-client-principal', localPrincipal(user.oid, 'access_as_user', authorized.requiredRole)); + headers.set('x-ms-api-role', authorized.requiredRole); return fetcher(target, { ...options, headers }); }, }); @@ -119,7 +95,7 @@ export async function createLocalApp(fetcher: typeof fetch = fetch, services?: L const runtime = runtimeFor(); return { version: 1, id: runtime.id, mode: runtime.mode, origins: runtime.origins, container, sourceFingerprint: provenance.sourceFingerprint, authentication, - profile: authorized ? 'role-based-data' : services ? 'foundation' : 'sql-only' }; + profile: 'role-based-data' }; }); if (authentication.mode === 'preview') { app.get('/__dev/identity', async (_request, reply) => { @@ -163,7 +139,7 @@ export async function createLocalApp(fetcher: typeof fetch = fetch, services?: L const readinessStatus = z.discriminatedUnion('status', [ z.strictObject({ status: z.literal('ready') }), z.strictObject({ status: z.literal('not-ready'), - dependency: z.enum(['data', 'blob', 'queue', 'functions']).optional() }), + dependency: z.enum(['data']).optional() }), ]); const readinessPause = () => new Promise(done => setTimeout(done, 1_000)); type LocalApp = Awaited>; @@ -204,13 +180,13 @@ export async function startLocalGateway(app: LocalApp, container: string, pause } } -export async function serveLocalApp(container = runtimeFor().defaultSql, sqlOnly = false, authorized?: RoleBasedProfile): Promise { +export async function serveLocalApp(container: string, authorized: RoleBasedProfile): Promise { const authentication = readLocalAuthentication(); - const app = await createLocalApp(fetch, sqlOnly ? undefined : await localServiceAdapters(container), authorized, container, authentication); + const app = await createLocalApp(fetch, authorized, container, authentication); await startLocalGateway(app, container); console.log(`Local browser app ready at ${localAppOrigin}.`); console.log(authentication.mode === 'preview' ? 'Local preview identities are simulated; do not expose remotely.' - : 'Microsoft Entra sign-in is verified; local DAB claims and backend services remain development simulations.'); + : 'Microsoft Entra sign-in is verified; local DAB claims remain a development simulation.'); if (authentication.mode === 'preview') console.log(`Developer identity testing: ${new URL('/__dev/identity', localAppOrigin).href}`); if (authentication.mode === 'entra') console.log(`Microsoft sign-in redirect URI: ${new URL(authRedirectPath, localAppOrigin).href}`); for (const signal of ['SIGINT', 'SIGTERM'] as const) { diff --git a/src/local-cli.ts b/src/local-cli.ts index 31ba1ca..aff4dd6 100644 --- a/src/local-cli.ts +++ b/src/local-cli.ts @@ -3,10 +3,11 @@ import { join, resolve } from 'node:path'; const [command = 'help', selection] = process.argv.slice(2); const projectRoot = resolve(process.env.SQL_APPS_PROJECT_HOME ?? process.cwd()); +const profileCommands = ['app', 'serve', 'role-based-app', 'role-based-serve', 'app-test', 'init', 'data']; if (command === 'help') { - console.log('Usage: npm run local -- [sql-container-name]\nWorkspace: workspace-plan | workspace-init . Select isolation or explicit legacy compatibility before app startup. workspace-plan is read-only; initialization records selection, never deletes existing resources. app starts SQL/DAB/Azurite/Functions/browser; serve reuses running services. app-check inspects legacy sample objects without dropping data. services-test restarts only verified owned services and needs separate approval. stop-services preserves data. No Azure subscription or Entra registration required. Loopback-only claim simulation is not production authentication.'); + console.log('Usage: npm run local -- [sql-container-name]\nWorkspace: workspace-plan | workspace-init . Select isolation or explicit legacy compatibility before app startup. workspace-plan is read-only; initialization records selection, never deletes existing resources. app starts SQL, Data API builder and the browser gateway using role-based-data.json and the authorized procedures in dab/dab-config.json; serve reuses running SQL and DAB. app-check inspects legacy sample objects without dropping data. No Azure subscription or Entra registration required. Loopback-only claim simulation is not production authentication. role-based-app and role-based-serve remain as aliases of app and serve.'); console.log('SQL recovery: recover-sql [workspace-sql-container] explicitly recreates only a stopped isolated SQL container, preserving its image version, credentials, ports and labeled volume. Obtain approval first; no volume reset or external/legacy recovery.'); - console.log('Role-based-data: role-based-app [sql-container] starts only SQL/DAB/browser using role-based-data.json and authorized procedure permissions in dab/dab-config.json. role-based-serve resumes that profile. Default local preview is simulated. SQL_APPS_LOCAL_AUTH=entra requires AZURE_TENANT_ID and API_CLIENT_ID and a registered workspace callback; local DAB still uses claim simulation.'); + console.log('Authentication: default local preview is simulated. SQL_APPS_LOCAL_AUTH=entra requires AZURE_TENANT_ID and API_CLIENT_ID and a registered workspace callback; local DAB still uses claim simulation.'); console.log('Selected application: selected-app provisions only its isolated SQL database/login and DAB, then serves the browser. selected-serve reuses verified running services. selected-stop removes only that owned DAB container and preserves SQL data. Build with npm run app:build -- local-simulation first. These commands never select an example in the default application.'); console.log('Selected acceptance: selected-test runs the opted-in example acceptance suite against its already-running local app; obtain approval for synthetic writes and scoped cleanup first.'); } else { @@ -16,31 +17,27 @@ if (command === 'help') { { localCommand, localOrigin, sqlImage, dabImage }, { readRoleBasedProfile, validateRoleBasedDab }, { run }, - { startLocalServices, stopLocalServices, runLocalMaintenance, localServiceImages }, { startLocalApplication }, { runtimeFor, proposeWorkspace, initializeWorkspace }, { checkWorkspacePorts }, { assertConsent }, { readLocalAuthentication }, { readSqlSources, roleBasedProcedureProblem }, - { z }, ] = await Promise.all([ import('./local.js'), import('./role-based-profile.js'), import('./process.js'), - import('./local-services.js'), import('./local-startup.js'), import('./workspace.mjs'), import('./local-ownership.js'), import('./local-consent.mjs'), import('./local-auth.mjs'), import('./role-based-rules.mjs'), - Promise.resolve(zod), ]); const container = selection ?? runtimeFor(undefined, projectRoot).defaultSql; if (['selected-app', 'selected-serve', 'selected-test'].includes(command) && (!selection || process.argv.length !== 4)) throw new Error('Provide exactly one selected application artifact directory'); - const profile = command.startsWith('role-based-') + const profile = profileCommands.includes(command) ? await readRoleBasedProfile(join(projectRoot, 'role-based-data.json')) : undefined; if (profile) { @@ -49,7 +46,9 @@ if (command === 'help') { const procedureProblem = roleBasedProcedureProblem(dab, await readSqlSources(projectRoot)); if (procedureProblem) throw new Error(`${procedureProblem} Add each procedure to the SQL project under sql/ before startup.`); } - if (['app', 'serve', 'serve-sql', 'role-based-app', 'role-based-serve'].includes(command)) readLocalAuthentication(); + const app = command === 'app' || command === 'role-based-app'; + const serve = command === 'serve' || command === 'role-based-serve'; + if (app || serve) readLocalAuthentication(); await assertConsent(projectRoot, { operation: command, ...(selection === undefined ? {} : { selection }) }); if (command === 'selected-app' || command === 'selected-serve') { if (!selection) throw new Error('Provide exactly one selected application artifact directory'); @@ -71,39 +70,27 @@ if (command === 'help') { } else if (command === 'workspace-init') { if (!selection) throw new Error('Explicitly select workspace-init or workspace-init legacy after reviewing workspace-plan.'); console.log(JSON.stringify(await initializeWorkspace(projectRoot, selection === 'legacy' ? 'legacy' : Number(selection)), null, 2)); - } else if (command === 'app' || command === 'serve' || command === 'role-based-app' || command === 'role-based-serve') { + } else if ((app || serve) && profile) { if (process.env.NODE_ENV === 'production') throw new Error('Local development server is disabled in production'); const { serveLocalApp } = await import('./local-app.js'); - if (command === 'app' || command === 'role-based-app') { + if (app) { await startLocalApplication(container, { command: async (step, name) => { if (step === 'start-sql') { if (runtimeFor(name, projectRoot).mode === 'legacy-unselected') throw new Error('Explicit workspace selection required: run workspace-plan, then approve workspace-init or workspace-init legacy.'); - await checkWorkspacePorts(name, run, profile ? { sql: sqlImage, data: dabImage } : localServiceImages(name)); + await checkWorkspacePorts(name, run, { sql: sqlImage, data: dabImage }); } - await localCommand(profile && step === 'start-sql' && !runtimeFor(name, projectRoot).ownsSqlName ? 'verify' : step, name, run, profile); + await localCommand(step === 'start-sql' && !runtimeFor(name, projectRoot).ownsSqlName ? 'verify' : step, name, run, profile); }, - services: startLocalServices, serve: name => serveLocalApp(name, Boolean(profile), profile), - }, console.log, Boolean(profile)); - } else await serveLocalApp(container, Boolean(profile), profile); - } else if (command === 'serve-sql') { - const { serveLocalApp } = await import('./local-app.js'); - await serveLocalApp(container, true); - } else if (command === 'services') { - await startLocalServices(container); - } else if (command === 'stop-services') { - await stopLocalServices(container); - } else if (command === 'maintain') { - console.log(await runLocalMaintenance(container)); - } else if (command === 'services-test') { - const { testLocalServices } = await import('./local-services-smoke.js'); - await testLocalServices(container); - } else if (command === 'app-test') { + serve: name => serveLocalApp(name, profile), + }, console.log); + } else await serveLocalApp(container, profile); + } else if (command === 'app-test' && profile) { const { testLocalApp } = await import('./local-app-smoke.js'); - await testLocalApp(); - } else await localCommand(command, container, run); - if (!['app', 'serve', 'serve-sql', 'role-based-app', 'role-based-serve', 'selected-app', 'selected-serve', 'workspace-plan', 'workspace-init'].includes(command)) { - console.log(command === 'data' ? `Local DAB ready at ${localOrigin}; use npm run local -- api-test` : `Local ${command} passed`); + await testLocalApp(profile); + } else await localCommand(command, container, run, profile); + if (!app && !serve && !['selected-app', 'selected-serve', 'workspace-plan', 'workspace-init'].includes(command)) { + console.log(command === 'data' ? `Local DAB ready at ${localOrigin}; use npm run local -- app-test after serve` : `Local ${command} passed`); } } catch (error) { const messages = [error instanceof zod.z.ZodError ? `Configuration is invalid:\n${zod.z.prettifyError(error)}` : diff --git a/src/local-consent.d.mts b/src/local-consent.d.mts index 339ef05..b4192c4 100644 --- a/src/local-consent.d.mts +++ b/src/local-consent.d.mts @@ -23,7 +23,7 @@ export function assertConsent(home: string, request: LocalRequest): Promise; export function acknowledgeTerms(home: string, document: string): Promise; export const SQL_TERMS: { readonly url: string; readonly statement: string }; -export interface BundleRequest { profile: 'foundation' | 'role-based-data'; workspace?: string } +export interface BundleRequest { profile: 'role-based-data'; workspace?: string } export interface BundlePlan { version: 1; home: string; diff --git a/src/local-consent.mjs b/src/local-consent.mjs index 20bb2bf..e77a10f 100644 --- a/src/local-consent.mjs +++ b/src/local-consent.mjs @@ -7,7 +7,7 @@ import { readProjectManifest } from './project-contract.mjs'; import { toolingHome } from './tooling-context.mjs'; const digest = value => createHash('sha256').update(value).digest('hex'); -const launchOperations = { foundation: 'app', 'role-based-data': 'role-based-app' }; +const launchOperations = { 'role-based-data': 'app' }; export const SQL_TERMS = Object.freeze({ url: 'https://aka.ms/azuresqldb-container', statement: 'Local SQL startup passes ACCEPT_EULA=Y to the Azure SQL Database container (private preview). ' + @@ -17,8 +17,7 @@ export const SQL_TERMS = Object.freeze({ const readOnly = new Set(['help', 'workspace-plan', 'verify', 'app-check']); const scopeNames = ['restore', 'build', 'workspace', 'sql-start', 'schema', 'services', 'synthetic', 'stop', 'maintenance']; const runtimeCommands = new Set([ - 'app', 'serve', 'serve-sql', 'role-based-app', 'role-based-serve', 'services', 'stop-services', - 'maintain', 'start-sql', 'init', 'test', 'data', 'api-test', 'app-test', 'services-test', 'stop', + 'app', 'serve', 'role-based-app', 'role-based-serve', 'start-sql', 'init', 'test', 'data', 'app-test', 'stop', 'recover-sql', 'selected-app', 'selected-serve', 'selected-stop', 'selected-test', ]); const validTime = value => typeof value === 'string' && Number.isFinite(Date.parse(value)); @@ -136,8 +135,6 @@ async function consentInputs(home) { function toolingInput(path, packaged) { if (!packaged) return path; if (path.startsWith('src/') && path.endsWith('.ts')) return `dist/${path.slice(0, -3)}.js`; - if (path === 'functions/src') return 'dist/functions/src'; - if (path.startsWith('functions/')) return `assets/${path}`; if (path.startsWith('scripts/')) return `dist/${path}`; return path; } @@ -159,10 +156,10 @@ async function requirementsFor(home, request, proposed) { let artifact; const add = async (scope, target, effects, paths = [], reusable = true, artifactPaths = []) => { const projectPaths = context.generated - ? paths.filter(path => !path.startsWith('src/') && !path.startsWith('scripts/') && !path.startsWith('functions/')) + ? paths.filter(path => !path.startsWith('src/') && !path.startsWith('scripts/')) : paths; const implementationPaths = context.generated - ? paths.filter(path => path.startsWith('src/') || path.startsWith('scripts/') || path.startsWith('functions/')) + ? paths.filter(path => path.startsWith('src/') || path.startsWith('scripts/')) .map(path => toolingInput(path, context.packaged)) : []; const inputs = await inputHashes(home, projectPaths); @@ -211,8 +208,7 @@ async function requirementsFor(home, request, proposed) { !/^[a-z][a-z0-9-]{0,31}$/.test(artifact.name)) throw new Error('Selected artifact belongs to another workspace/profile'); } if (operation === 'selected-stop' && !/^[a-z][a-z0-9-]{0,31}$/.test(selection ?? '')) throw new Error('Invalid application selection'); - const profile = operation.startsWith('role-based-') ? 'role-based-data' : selected ? `selected:${artifact?.name ?? selection}` : - ['serve-sql', 'start-sql', 'init', 'data', 'test', 'api-test', 'stop', 'recover-sql'].includes(operation) ? 'data' : 'foundation'; + const profile = selected ? `selected:${artifact?.name ?? selection}` : 'role-based-data'; const target = JSON.stringify({ home, workspace: runtime.id, mode: runtime.mode, ports: runtime.ports, container: runtime.sqlContainer, database: artifact ? `${runtime.database}_${artifact.name.replaceAll('-', '_')}` : runtime.database, profile }); @@ -222,47 +218,31 @@ async function requirementsFor(home, request, proposed) { // Contracts bind launcher code, target and effects; editing app SQL, DAB config or role settings must not re-prompt. const schema = () => add('schema', target, ['Publish non-destructive schema and runtime SQL grants; preserve existing data.'], artifact ? ['src/local.ts', 'src/selected-local.ts'] : - ['src/local.ts', ...(profile === 'role-based-data' ? ['src/role-based-profile.ts'] : [])], + ['src/local.ts', 'src/role-based-profile.ts'], true, artifact ? ['sql', 'setup', 'dab/dab-config.json'] : []); - const settings = profile === 'foundation' ? await readJson(runtime.settingsPath) : null; - if (settings) object(settings, ['processing', 'automaticProgress', 'jobRetry', 'tracing', 'progressIntervalMs', - 'staleJobMinutes', 'retentionDays', 'traceRetentionDays'], 'local processing settings'); const services = () => add('services', target, [ - `Start/reuse ${profile} loopback services; download/build images and write owned configuration.`, - ...(profile === 'foundation' ? [ - 'Run background file processing and timed stale-job recovery/retention cleanup, including expired traces.', - `Processing/retention settings: ${JSON.stringify(settings ?? { defaults: true })}.`, - ] : []), - ], ['src/artifacts.ts', 'src/local.ts', 'src/local-app.ts', ...(profile === 'foundation' ? - ['src/local-services.ts', 'src/local-settings.ts', 'src/local-maintenance.ts', 'functions/src', - 'functions/local.Dockerfile', 'functions/local-package.json', 'functions/local-host.json'] : []), - ...(profile === 'role-based-data' ? ['src/role-based-profile.ts'] : []), - ...(artifact ? ['src/selected-local.ts'] : [])], true, artifact ? ['server', 'dab/dab-config.json'] : []); + `Start/reuse ${profile} loopback services; download images and write owned configuration.`, + ], ['src/artifacts.ts', 'src/local.ts', 'src/local-app.ts', + ...(artifact ? ['src/selected-local.ts'] : ['src/role-based-profile.ts'])], true, artifact ? ['server', 'dab/dab-config.json'] : []); const composite = ['app', 'role-based-app', 'selected-app'].includes(operation); if (composite || operation === 'init') { await restore(); await build(); } - if ((composite && !(profile === 'role-based-data' && !runtime.ownsSqlName)) || operation === 'start-sql' || operation === 'recover-sql') await sql(); + if ((composite && (selected || runtime.ownsSqlName)) || operation === 'start-sql' || operation === 'recover-sql') await sql(); if (composite || operation === 'init') await schema(); - if (composite || ['data', 'services', 'serve', 'serve-sql', 'role-based-serve', 'selected-serve', 'services-test'].includes(operation)) await services(); - if (['test', 'api-test', 'app-test', 'services-test', 'selected-test'].includes(operation)) { - await add('synthetic', `${target}:${operation}`, [`Run ${operation}; create synthetic fixtures and perform its documented scoped cleanup${operation === 'services-test' ? ', including owned service restart' : ''}.`], + if (composite || ['data', 'serve', 'role-based-serve', 'selected-serve'].includes(operation)) await services(); + if (['test', 'app-test', 'selected-test'].includes(operation)) { + await add('synthetic', `${target}:${operation}`, [`Run ${operation}; create synthetic fixtures and perform its documented scoped cleanup.`], artifact ? ['src/selected-local.ts', `examples/${artifact.name}/demo/smoke.mjs`] : - ['src/local.ts', 'sql/local/acceptance.sql', - ...(operation === 'services-test' ? ['src/local-services-smoke.ts'] : - operation === 'app-test' ? ['src/local-app-smoke.ts', 'src/local-job-data.ts'] : [])], + ['src/local.ts', 'sql/local/acceptance.sql', ...(operation === 'app-test' ? ['src/local-app-smoke.ts'] : [])], true, artifact ? ['sql', 'setup', 'server', 'dab/dab-config.json'] : []); } - if (['app', 'init', 'data'].includes(operation)) { + if (['app', 'role-based-app', 'init'].includes(operation)) { await add('synthetic', `${sqlTarget}:sql-acceptance`, - ['Run the built-in SQL acceptance suite during initialization/data startup; write and clean up synthetic fixtures.'], + ['Run the built-in read-only SQL acceptance check on the least-privilege app user during initialization.'], ['src/local.ts', 'sql/local/acceptance.sql']); } - if (['stop', 'stop-services', 'selected-stop'].includes(operation)) { - await add('stop', target, [`Stop/remove only verified owned ${profile} services; preserve SQL/storage volumes and credentials.`], - ['src/local.ts', 'src/local-services.ts', 'src/selected-local.ts']); - } - if (operation === 'maintain') { - await add('maintenance', target, ['Recover stale jobs and delete expired jobs, snapshots and traces under configured retention. One operation only.'], - ['src/local-maintenance.ts', 'src/local-settings.ts', ...(settings === null ? [] : [relative(home, runtime.settingsPath)])], false); + if (['stop', 'selected-stop'].includes(operation)) { + await add('stop', target, [`Stop/remove only verified owned ${profile} services; preserve SQL volumes and credentials.`], + ['src/local.ts', 'src/selected-local.ts']); } if (operation === 'recover-sql') { requirements.length = 0; @@ -312,7 +292,7 @@ export async function planBundle(value, options) { object(options, ['profile', 'workspace'], 'bundle request'); const home = await realpath(value); const operation = launchOperations[options.profile]; - if (!operation) throw new Error('Select bundle profile foundation or role-based-data'); + if (!operation) throw new Error('Select bundle profile role-based-data'); if (options.workspace !== undefined && typeof options.workspace !== 'string') throw new Error('Invalid bundle workspace selection'); const existing = readWorkspace(home); const requirements = []; diff --git a/src/local-job-data.ts b/src/local-job-data.ts deleted file mode 100644 index e07b0f6..0000000 --- a/src/local-job-data.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { localPrincipal } from './local.js'; -import { fileJobSchema, type FileJob } from './file-jobs.js'; - -export async function deleteLocalJob( - jobs: Pick, 'get' | 'delete'>, - oid: string, id: string, removeSnapshot: () => Promise, signal?: AbortSignal, -): Promise { - const row = await jobs.get(oid, id, signal); - if (row && !['completed', 'failed'].includes(row.status)) { - throw Object.assign(new Error('Wait for processing to finish before deleting this job'), { statusCode: 409 }); - } - if (row) await jobs.delete(oid, id, signal); - signal?.throwIfAborted(); - await removeSnapshot(); - return row !== undefined; -} - -export function localJobData(origin: string, fetcher: typeof fetch = fetch) { - async function request(oid: string, path: string, method = 'GET', body?: unknown, signal?: AbortSignal) { - const response = await fetcher(new URL(path, origin), { - method, headers: { - 'x-ms-client-principal': localPrincipal(oid, 'access_as_user', 'processor'), 'x-ms-api-role': 'processor', - 'content-type': 'application/json', ...(method === 'PATCH' ? { 'if-match': '*' } : {}), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), - redirect: 'error', - }); - if (!response.ok) throw new Error(`Job data operation failed (${response.status})`); - return response; - } - async function page(oid: string, path: string): Promise<{ rows: FileJob[]; next?: string }> { - const url = new URL(path, origin); - if (url.origin !== new URL(origin).origin || url.pathname !== '/api/FileJob' || - url.username || url.password || url.hash) throw new Error('Invalid job pagination'); - const body = await (await request(oid, url.pathname + url.search)).json() as { - value: unknown[]; - nextLink?: string; - }; - if (!Array.isArray(body.value)) throw new Error('Invalid job response'); - if (body.nextLink !== undefined && (typeof body.nextLink !== 'string' || !body.nextLink)) { - throw new Error('Invalid job continuation'); - } - return { - rows: body.value.map(value => fileJobSchema.parse(value)), - ...(body.nextLink ? { next: body.nextLink } : {}), - }; - } - async function allPages(oid: string, first: string): Promise { - const rows: FileJob[] = []; - let next: string | undefined = first; - const visited = new Set(); - while (next) { - const url = new URL(next, origin); - if (visited.has(url.href)) throw new Error('Invalid job pagination'); - visited.add(url.href); - const current = await page(oid, next); - rows.push(...current.rows); - next = current.next; - } - return rows; - } - return { - async create(oid: string, item: { id: string; filename: string; blob_key: string; parent_job_id?: string }) { - await request(oid, '/api/FileJob', 'POST', item); - }, - async get(oid: string, id: string, signal?: AbortSignal): Promise { - const response = await request(oid, `/api/FileJob?$filter=id eq ${id}`, 'GET', undefined, signal); - const body = await response.json() as { value: unknown[] }; - if (!Array.isArray(body.value)) throw new Error('Invalid job response'); - return body.value[0] === undefined ? undefined : fileJobSchema.parse(body.value[0]); - }, - async list(oid: string): Promise { - const activeQuery = new URLSearchParams({ - '$filter': "status eq 'queued' or status eq 'processing'", - '$orderby': 'created_at desc', - }); - const terminalQuery = new URLSearchParams({ - '$filter': "status eq 'completed' or status eq 'failed'", - '$orderby': 'created_at desc', - '$first': '100', - }); - const active = await allPages(oid, `/api/FileJob?${activeQuery}`); - const terminal = await page(oid, `/api/FileJob?${terminalQuery}`); - return [...new Map([...active, ...terminal.rows].map(job => [job.id, job])).values()]; - }, - async listAll(oid: string): Promise { - return allPages(oid, '/api/FileJob'); - }, - async update(oid: string, id: string, changes: object, signal?: AbortSignal) { - await request(oid, `/api/FileJob/id/${encodeURIComponent(id)}`, 'PATCH', - { ...changes, updated_at: new Date().toISOString() }, signal); - }, - async delete(oid: string, id: string, signal?: AbortSignal) { - await request(oid, `/api/FileJob/id/${encodeURIComponent(id)}`, 'DELETE', undefined, signal); - }, - }; -} diff --git a/src/local-job-snapshots.ts b/src/local-job-snapshots.ts deleted file mode 100644 index 5f1c966..0000000 --- a/src/local-job-snapshots.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { BlobClient, BlobLeaseClient, ContainerClient } from '@azure/storage-blob'; -import { deleteLocalJob, type localJobData } from './local-job-data.js'; - -function blobMissing(error: unknown): boolean { - return error instanceof Error && 'code' in error && error.code === 'BlobNotFound'; -} - -function leaseAlreadyHeld(error: unknown): boolean { - return error instanceof Error && 'code' in error && error.code === 'LeaseAlreadyPresent'; -} - -const leaseSeconds = 60; -const renewalIntervalMs = 30_000; - -function renewUntilStopped(lease: BlobLeaseClient, controller: AbortController) { - let stopped = false; - let timer: NodeJS.Timeout | undefined; - let renewal = Promise.resolve(); - let failure: unknown; - const schedule = () => { - if (stopped) return; - timer = setTimeout(() => { - renewal = lease.renewLease() - .then(() => { schedule(); }) - .catch(error => { - failure = error; - controller.abort(error); - }); - }, renewalIntervalMs); - }; - schedule(); - return async () => { - stopped = true; - if (timer) clearTimeout(timer); - await renewal; - return failure; - }; -} - -async function leased(lease: BlobLeaseClient, action: (leaseId: string, signal: AbortSignal) => Promise): Promise { - const controller = new AbortController(); - const stopRenewal = renewUntilStopped(lease, controller); - let result: T | undefined; - let failure: unknown; - try { - result = await action(lease.leaseId, controller.signal); - } catch (error) { - failure = error; - } - const renewalFailure = await stopRenewal(); - if (renewalFailure && renewalFailure !== failure) { - failure = failure ? - new AggregateError([failure, renewalFailure], 'Job operation and snapshot lease renewal failed') : renewalFailure; - } - try { - await lease.releaseLease(); - } catch (error) { - if (!blobMissing(error)) { - failure = failure ? - new AggregateError([failure, error], 'Job operation and snapshot lease release failed') : error; - } - } - if (failure) throw failure; - return result as T; -} - -export async function withJobSnapshotLease(blob: BlobClient, action: (signal: AbortSignal) => Promise): Promise { - const lease = blob.getBlobLeaseClient(); - await lease.acquireLease(leaseSeconds); - return leased(lease, (_leaseId, signal) => action(signal)); -} - -export async function tryWithJobSnapshotLease( - blob: BlobClient, action: (signal: AbortSignal) => Promise, -): Promise { - try { - await withJobSnapshotLease(blob, action); - return true; - } catch (error) { - if (leaseAlreadyHeld(error)) return false; - throw error; - } -} - -export async function deleteLocalJobSnapshot( - jobs: Pick, 'get' | 'delete'>, - files: ContainerClient, tenantId: string, oid: string, id: string, -): Promise { - const blob = files.getBlockBlobClient(`${tenantId}/${oid}/jobs/${id}`); - const lease = blob.getBlobLeaseClient(); - try { - await lease.acquireLease(leaseSeconds); - } catch (error) { - if (!blobMissing(error)) throw error; - return deleteLocalJob(jobs, oid, id, async () => {}); - } - return leased(lease, (leaseId, signal) => deleteLocalJob(jobs, oid, id, - () => blob.deleteIfExists({ conditions: { leaseId }, abortSignal: signal }), signal)); -} diff --git a/src/local-maintenance.ts b/src/local-maintenance.ts deleted file mode 100644 index 0352cdb..0000000 --- a/src/local-maintenance.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { ContainerClient } from '@azure/storage-blob'; -import { localJobData } from './local-job-data.js'; -import { deleteLocalJobSnapshot, tryWithJobSnapshotLease } from './local-job-snapshots.js'; -import type { LocalSettings } from './local-settings.js'; -import type { FileJob } from './file-jobs.js'; - -export const localTenantId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; -export const localOwnerIds = ['11111111-1111-4111-8111-111111111111', '22222222-2222-4222-8222-222222222222']; - -export function sqlTime(value: string | undefined): number { - if (!value) throw new Error('Job timestamp is missing'); - const time = Date.parse(/(?:Z|[+-]\d\d:\d\d)$/.test(value) ? value : `${value}Z`); - if (!Number.isFinite(time)) throw new Error('Invalid job timestamp'); - return time; -} - -export function maintenanceAction(job: FileJob, settings: LocalSettings, now: number): 'recover' | 'remove' | undefined { - const age = now - sqlTime(job.updated_at ?? job.created_at); - if (['queued', 'processing'].includes(job.status) && age >= settings.staleJobMinutes * 60_000) return 'recover'; - if (settings.retentionDays > 0 && ['completed', 'failed'].includes(job.status) && age >= settings.retentionDays * 86400_000) return 'remove'; - return undefined; -} - -export async function maintainLocalJobs( - jobs: ReturnType, files: ContainerClient, traces: ContainerClient, - settings: LocalSettings, now = Date.now(), owners: readonly string[] = localOwnerIds, -) { - const result = { recovered: 0, removedJobs: 0, removedSnapshots: 0, removedTraces: 0 }; - for (const oid of owners) { - const rows = await jobs.listAll(oid); - const active = new Set(rows.map(job => job.id)); - for (const job of rows) { - const action = maintenanceAction(job, settings, now); - if (action === 'recover') { - const snapshot = files.getBlockBlobClient(`${localTenantId}/${oid}/jobs/${job.id}`); - const recovered = await tryWithJobSnapshotLease(snapshot, signal => - jobs.update(oid, job.id, { - status: 'failed', - error: 'Job exceeded the recovery timeout; retry the saved snapshot when services are healthy', - }, signal)); - if (recovered) result.recovered++; - } else if (action === 'remove') { - const removed = await deleteLocalJobSnapshot(jobs, files, localTenantId, oid, job.id); - active.delete(job.id); - if (removed) result.removedJobs++; - } - } - if (settings.retentionDays > 0) { - const prefix = `${localTenantId}/${oid}/jobs/`; - for await (const blob of files.listBlobsFlat({ prefix })) { - const id = blob.name.slice(prefix.length); - if (!active.has(id) && blob.properties.lastModified && now - blob.properties.lastModified.getTime() >= Math.max(86400_000, settings.retentionDays * 86400_000)) { - await files.getBlockBlobClient(blob.name).deleteIfExists(); - result.removedSnapshots++; - } - } - } - for await (const blob of traces.listBlobsFlat({ prefix: `${localTenantId}/${oid}/` })) { - if (blob.properties.lastModified && now - blob.properties.lastModified.getTime() >= settings.traceRetentionDays * 86400_000) { - await traces.getBlockBlobClient(blob.name).deleteIfExists(); - result.removedTraces++; - } - } - } - return result; -} diff --git a/src/local-ownership.ts b/src/local-ownership.ts index fb87166..fc91580 100644 --- a/src/local-ownership.ts +++ b/src/local-ownership.ts @@ -30,11 +30,6 @@ export async function checkWorkspacePorts(container: string, runner: Run, images const components: [number | null, string | null, string | null][] = [ [runtime.ports.gateway, null, null], [runtime.ports.data, runtime.names.data, 'data'], - ...(images.functions ? [[runtime.ports.functions, runtime.names.functions, 'functions'] as [number, string, string]] : []), - ...(images.storage ? [ - [runtime.ports.blob, runtime.names.storage, 'storage'] as [number, string, string], - [runtime.ports.queue, runtime.names.storage, 'storage'] as [number, string, string], - ] : []), ]; if (runtime.ownsSqlName) components.push([runtime.ports.sql, container, 'sql']); for (const [port, name, role] of components) { diff --git a/src/local-services-smoke.ts b/src/local-services-smoke.ts deleted file mode 100644 index 99ff65e..0000000 --- a/src/local-services-smoke.ts +++ /dev/null @@ -1,305 +0,0 @@ -import assert from 'node:assert/strict'; -import { createHash, randomUUID } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; -import { QueueClient } from '@azure/storage-queue'; -import { BlobServiceClient } from '@azure/storage-blob'; -import { z } from 'zod'; -import { SqlAppsClient, ApiError } from './client.js'; -import { developmentUsers, localAppOrigin } from './local-app.js'; -import { ensureOwned, localFunctionsOrigin, serviceNames, storageConnection } from './local-services.js'; -import { localJobData } from './local-job-data.js'; -import { localOrigin, localPrincipal } from './local.js'; -import { run } from './process.js'; -import { maintainLocalJobs } from './local-maintenance.js'; -import { localSettingsSchema } from './local-settings.js'; -import { runtimeFor } from './workspace.mjs'; - -export async function testLocalServices(container: string): Promise { - const names = serviceNames(container); - if (!await ensureOwned(names.storage, 'storage') || !await ensureOwned(names.functions, 'functions')) { - throw new Error('Start project-owned services before running services-test'); - } - const tokens: string[] = []; - const files: { client: SqlAppsClient; name: string }[] = []; - const jobs: { client: SqlAppsClient; id: string }[] = []; - const state = z.object({ key: z.string(), secret: z.string() }).parse(JSON.parse(await readFile(names.state, 'utf8'))); - const data = localJobData(localOrigin); - const owner = developmentUsers[0]!.id; - const other = developmentUsers[1]!.id; - const tenantId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; - let functionsStopped = false; - const offlineName = `${names.functions}-offline-proof`; - let offlineStarted = false; - let failure: unknown; - const session = async (user: string) => { - const response = await fetch(`${localAppOrigin}/local/session`, { method: 'POST', - headers: { origin: localAppOrigin, 'content-type': 'application/json' }, body: JSON.stringify({ user }) }); - assert.equal(response.status, 200); - const { token } = z.object({ token: z.string() }).parse(await response.json()); - tokens.push(token); - return new SqlAppsClient({ baseUrl: localAppOrigin, getAccessToken: async () => token, - fetch: (url, options) => { - const headers = new Headers(options?.headers); - headers.set('origin', localAppOrigin); - return fetch(url, { ...options, headers }); - } }); - }; - const denied = async (action: () => Promise, statuses = [403, 404]) => { - await assert.rejects(action, (error: unknown) => error instanceof ApiError && statuses.includes(error.status)); - }; - const wait = async (client: SqlAppsClient, id: string, status: 'completed' | 'failed') => { - for (let attempt = 0; attempt < 120; attempt++) { - const job = (await client.listJobs()).find(row => row.id === id); - if (job?.status === status) return job; - if (job?.status === 'failed' && status !== 'failed') throw new Error(`Processing failed: ${job.error}`); - await new Promise(resolve => setTimeout(resolve, 1_000)); - } - throw new Error(`Job did not become ${status}`); - }; - try { - const alice = await session(owner); - const bob = await session(other); - const name = `acceptance-${randomUUID()}.txt`; - files.push({ client: alice, name }, { client: bob, name }); - const bytes = Buffer.alloc(4 * 1024 * 1024, 65); - await alice.upload(name, bytes); - assert.deepEqual(Buffer.from(await alice.download(name)), bytes, 'Exact 4 MiB Blob roundtrip'); - await denied(() => alice.upload(name, Buffer.alloc(bytes.length + 1)), [413]); - assert.equal((await alice.listFiles()).files.find(file => file.name === name)?.size, bytes.length); - assert.equal((await bob.listFiles()).files.some(file => file.name === name), false); - await denied(() => bob.download(name)); - await denied(() => bob.deleteFile(name)); - await bob.upload(name, Buffer.from('Bob owns this separate file')); - assert.equal(Buffer.from(await bob.download(name)).toString(), 'Bob owns this separate file'); - assert.deepEqual(Buffer.from(await alice.download(name)), bytes); - console.log('Live Blob ownership, same-name isolation and exact 4 MiB boundary passed.'); - - const echo = await alice.echo({ message: 'Real Functions host acceptance' }); - assert.deepEqual(echo, { result: { user: { oid: owner, tenantId }, input: { message: 'Real Functions host acceptance' } } }); - const anonymous = await fetch(`${localFunctionsOrigin}/api/echo`, { method: 'POST', - headers: { 'content-type': 'application/json' }, body: '{}' }); - assert.equal(anonymous.status, 401, 'Local function requires its server-only secret'); - - await run('docker', ['stop', names.functions]); - functionsStopped = true; - const id = await alice.processFile(name); - jobs.push({ client: alice, id }); - assert.equal((await alice.listJobs()).find(job => job.id === id)?.status, 'queued'); - assert.equal((await bob.listJobs()).some(job => job.id === id), false); - await denied(() => bob.deleteJob(id)); - await denied(() => alice.deleteJob(id), [409]); - await denied(() => alice.request('/jobs', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ name, status: 'completed', owner_oid: other }) }), [400]); - for (const method of ['PATCH', 'DELETE']) { - await denied(() => alice.request(`/api/FileJob/id/${id}`, { - method, headers: { ...(method === 'PATCH' ? { 'content-type': 'application/json' } : {}), 'x-ms-api-role': 'processor', - 'x-ms-client-principal': localPrincipal(other, 'access_as_user', 'processor') }, - ...(method === 'PATCH' ? { body: JSON.stringify({ status: 'completed' }) } : {}), - })); - } - await assert.rejects(data.update(other, id, { status: 'completed' }), /404/); - const directRead = await fetch(`${localOrigin}/api/FileJob/id/${id}`, { headers: { - 'x-ms-client-principal': localPrincipal(owner), - } }); - assert.equal(directRead.status, 200); - assert.equal(JSON.stringify(await directRead.json()).includes('blob_key'), false, 'Client cannot see private snapshot keys'); - await alice.upload(name, Buffer.from('replaced after enqueue')); - await alice.deleteFile(name); - await run('docker', ['start', names.functions]); - functionsStopped = false; - const completed = await wait(alice, id, 'completed'); - assert.equal(completed.byte_count, bytes.length, 'Processor reads immutable original snapshot'); - assert.equal(completed.line_count, 1); - assert.equal(completed.sha256, createHash('sha256').update(bytes).digest('hex')); - const queue = new QueueClient(storageConnection(state.key), 'file-jobs'); - const message = { id, oid: owner, tenantId, key: `${tenantId}/${owner}/jobs/${id}` }; - await queue.sendMessage(Buffer.from(JSON.stringify(message)).toString('base64')); - await new Promise(resolve => setTimeout(resolve, 3_000)); - assert.deepEqual((await alice.listJobs()).find(job => job.id === id), completed, 'Duplicate delivery is idempotent'); - console.log('Real Functions HTTP/queue, snapshot integrity, role escalation denial and duplicate delivery passed.'); - - const { localCommand } = await import('./local.js'); - await localCommand('data', container, run); - await alice.upload(name, Buffer.from('DAB recreated without replacing Functions')); - const recreatedDataId = await alice.processFile(name); - jobs.push({ client: alice, id: recreatedDataId }); - const recreatedDataJob = await wait(alice, recreatedDataId, 'completed'); - assert.equal(recreatedDataJob.sha256, - createHash('sha256').update('DAB recreated without replacing Functions').digest('hex')); - console.log('Existing Functions processed a job after DAB recreation through stable Docker DNS.'); - - const failedId = randomUUID(); - const failedKey = `${tenantId}/${owner}/jobs/${failedId}`; - await data.create(owner, { id: failedId, filename: name, blob_key: failedKey }); - jobs.push({ client: alice, id: failedId }); - await queue.sendMessage(Buffer.from(JSON.stringify({ id: failedId, oid: owner, tenantId, key: failedKey })).toString('base64')); - const failed = await wait(alice, failedId, 'failed'); - assert.ok(failed.error); - console.log('Real queue retries and persisted failure status passed.'); - - await run('docker', ['restart', names.storage]); - assert.equal(Buffer.from(await bob.download(name)).toString(), 'Bob owns this separate file', 'Blob survives Azurite restart'); - assert.deepEqual((await alice.listJobs()).find(job => job.id === id), completed, 'SQL job results persist across service restart'); - console.log('Azurite volume persistence and SQL result persistence passed.'); - - if (await ensureOwned(offlineName, 'functions')) throw new Error('A previous offline-proof container needs explicit cleanup'); - const network = z.object({ Internal: z.literal(true) }).parse(JSON.parse( - await run('docker', ['network', 'inspect', '--format', '{{json .}}', names.network]))); - assert.equal(network.Internal, true); - const address = async (name: string) => { - const networks = z.record(z.string(), z.object({ IPAddress: z.string() })).parse(JSON.parse( - await run('docker', ['inspect', '--format', '{{json .NetworkSettings.Networks}}', name]))); - return z.ipv4().parse(networks[names.network]?.IPAddress); - }; - const runningImage = z.string().regex(/^sha256:[a-f0-9]{64}$/).parse( - (await run('docker', ['inspect', '--format', '{{.Image}}', names.functions])).trim()); - await run('docker', ['stop', names.functions]); - functionsStopped = true; - await alice.upload(name, Buffer.from('offline\nprocessing')); - const offlineId = await alice.processFile(name); - jobs.push({ client: alice, id: offlineId }); - offlineStarted = true; - await run('docker', ['run', '--detach', '--name', offlineName, '--label', 'sql-apps.local=functions', - ...runtimeFor(container).labels, - '--network', names.network, '--env', 'AzureWebJobsStorage', '--env', 'LOCAL_FUNCTION_SECRET', '--env', 'LOCAL_DAB_URL', - '--env', 'FUNCTIONS_WORKER_RUNTIME=node', '--env', 'SQL_APPS_LOCAL_FUNCTIONS=true', runningImage], - { env: { AzureWebJobsStorage: storageConnection(state.key, await address(names.storage)), - LOCAL_DAB_URL: `http://${names.data}:5000`, LOCAL_FUNCTION_SECRET: state.secret }, - redact: [state.key, state.secret] }); - assert.equal((await run('docker', ['inspect', '--format', '{{.Image}}', offlineName])).trim(), runningImage, - 'Offline proof must use the actual running worker image, not a moved tag or newer local build'); - const offlineResult = await wait(alice, offlineId, 'completed'); - assert.equal(offlineResult.sha256, createHash('sha256').update('offline\nprocessing').digest('hex')); - assert.equal(offlineResult.line_count, 2); - const offlineNetworks = JSON.parse(await run('docker', ['inspect', '--format', '{{json .NetworkSettings.Networks}}', offlineName])); - assert.deepEqual(Object.keys(offlineNetworks), [names.network], 'Offline worker has only an internal, no-egress network'); - await run('docker', ['rm', '--force', offlineName]); - offlineStarted = false; - await run('docker', ['start', names.functions]); - functionsStopped = false; - console.log('Identical Functions image processed a real job with no public network egress; offline binding startup passed.'); - - const ownTraces = await alice.listTraces(); - const submitted = ownTraces.find(span => span.name === 'snapshot.sql.queue.submit' && span.jobId === id); - const processed = ownTraces.find(span => span.name === 'queue.blob.analyze.sql.complete' && span.jobId === id); - assert.ok(submitted && processed, 'OpenTelemetry recorded submission and real worker spans'); - assert.equal(processed.traceId, submitted.traceId); - assert.equal(processed.parentSpanId, submitted.spanId, 'Queue delivery preserves trace parent'); - for (const name of ['blob.snapshot.write', 'sql.job.create', 'queue.job.send']) { - const child = ownTraces.find(span => span.jobId === id && span.name === name); - assert.ok(child, `Submission records ${name}`); - assert.equal(child.traceId, submitted.traceId); - assert.equal(child.parentSpanId, submitted.spanId); - } - for (const name of ['sql.job.processing', 'blob.snapshot.read', 'sql.job.complete']) { - const child = ownTraces.find(span => span.jobId === id && span.name === name); - assert.ok(child, `Worker records ${name}`); - assert.equal(child.traceId, processed.traceId); - assert.equal(child.parentSpanId, processed.spanId); - } - assert.equal(ownTraces.some(span => span.name === 'GET /jobs' && Date.parse(span.startedAt) >= Date.parse(submitted.startedAt)), - false, 'Automatic polling does not amplify traces'); - assert.equal((await bob.listTraces()).some(span => span.traceId === submitted.traceId), false, 'Trace dashboard is owner-scoped'); - assert.equal(JSON.stringify(ownTraces).includes(state.secret), false); - assert.equal(JSON.stringify(ownTraces).includes(state.key), false); - console.log('Owner-scoped OpenTelemetry gateway/submission/worker correlation passed.'); - - const stuckId = randomUUID(); - const stuckKey = `${tenantId}/${owner}/jobs/${stuckId}`; - const blobService = BlobServiceClient.fromConnectionString(storageConnection(state.key)); - const fileContainer = blobService.getContainerClient('files'); - const snapshot = Buffer.from('retry immutable snapshot\nwithout original file'); - await fileContainer.getBlockBlobClient(stuckKey).uploadData(snapshot); - await data.create(owner, { id: stuckId, filename: name, blob_key: stuckKey }); - jobs.push({ client: alice, id: stuckId }); - const aged = await fetch(`${localOrigin}/api/FileJob/id/${stuckId}`, { - method: 'PATCH', headers: { 'x-ms-client-principal': localPrincipal(owner, 'access_as_user', 'processor'), - 'x-ms-api-role': 'processor', 'content-type': 'application/json', 'if-match': '*' }, - body: JSON.stringify({ updated_at: new Date(Date.now() - 61 * 60_000).toISOString() }), - }); - assert.equal(aged.status, 200); - const recovered = await wait(alice, stuckId, 'failed'); - assert.match(recovered.error!, /recovery timeout/); - await denied(() => bob.retryJob(stuckId)); - await denied(() => alice.retryJob(id), [409]); - await alice.deleteFile(name); - const retriedId = await alice.retryJob(stuckId); - jobs.push({ client: alice, id: retriedId }); - const retried = await wait(alice, retriedId, 'completed'); - assert.equal(retried.parent_job_id?.toLowerCase(), stuckId); - assert.equal(retried.sha256, createHash('sha256').update(snapshot).digest('hex')); - assert.equal((await data.get(owner, stuckId))?.status, 'failed', 'Retry preserves old delivery/history'); - console.log('Real scheduled recovery and immutable-snapshot retry/history passed.'); - await testRetention(blobService, data); - } catch (error) { - failure = error; - throw error; - } finally { - const errors: unknown[] = []; - if (offlineStarted) { - try { await run('docker', ['rm', '--force', offlineName]); } - catch (error) { errors.push(error); } - } - - if (functionsStopped) { - try { await run('docker', ['start', names.functions]); } - catch (error) { errors.push(error); } - } - for (const fixture of jobs) { - try { - const row = await data.get(owner, fixture.id); - if (row && !['completed', 'failed'].includes(row.status)) { - await wait(fixture.client, fixture.id, 'completed'); - } - await fixture.client.deleteJob(fixture.id); - } catch (error) { errors.push(error); } - } - for (const fixture of files) { - try { await fixture.client.deleteFile(fixture.name); } - catch (error) { if (!(error instanceof ApiError && error.status === 404)) errors.push(error); } - } - for (const token of tokens) { - try { - const response = await fetch(`${localAppOrigin}/local/session`, { method: 'DELETE', - headers: { origin: localAppOrigin, authorization: `Bearer ${token}` } }); - if (!response.ok) throw new Error(`Session cleanup failed (${response.status})`); - } catch (error) { errors.push(error); } - } - if (errors.length) throw new AggregateError([...(failure ? [failure] : []), ...errors], 'Local service acceptance cleanup failed'); - } -} - -async function testRetention(blobs: BlobServiceClient, data: ReturnType) { - const owner = randomUUID(); - const tenant = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; - const terminal = randomUUID(); - const active = randomUUID(); - const orphan = randomUUID(); - const files = blobs.getContainerClient('files'); - const traces = blobs.getContainerClient('traces'); - const prefix = `${tenant}/${owner}/`; - const snapshotKeys = [terminal, active, orphan].map(id => `${prefix}jobs/${id}`); - const sourceKey = `${prefix}source.txt`; - const traceKey = `${prefix}retention-fixture.json`; - try { - for (const key of [...snapshotKeys, sourceKey]) await files.getBlockBlobClient(key).uploadData(Buffer.from('retention fixture')); - await traces.getBlockBlobClient(traceKey).uploadData(Buffer.from('{}')); - for (const id of [terminal, active]) await data.create(owner, { id, filename: 'source.txt', blob_key: `${prefix}jobs/${id}` }); - await data.update(owner, terminal, { status: 'completed' }); - const settings = localSettingsSchema.parse({ retentionDays: 1, staleJobMinutes: 10080, traceRetentionDays: 1 }); - const result = await maintainLocalJobs(data, files, traces, settings, Date.now() + 2 * 86400_000, [owner]); - assert.equal(result.removedJobs, 1); - assert.equal(result.removedSnapshots, 1); - assert.equal(result.removedTraces, 1); - assert.equal(result.recovered, 0); - assert.equal(await data.get(owner, terminal), undefined); - assert.equal((await data.get(owner, active))?.status, 'queued'); - assert.equal(await files.getBlockBlobClient(`${prefix}jobs/${active}`).exists(), true); - assert.equal(await files.getBlockBlobClient(sourceKey).exists(), true, 'Retention never deletes source uploads'); - console.log('Real opt-in retention deletes only expired terminal jobs/orphans/traces and preserves active snapshots/source files.'); - } finally { - for (const id of [terminal, active]) if (await data.get(owner, id)) await data.delete(owner, id); - for (const key of [...snapshotKeys, sourceKey]) await files.getBlockBlobClient(key).deleteIfExists(); - await traces.getBlockBlobClient(traceKey).deleteIfExists(); - } -} diff --git a/src/local-services.ts b/src/local-services.ts deleted file mode 100644 index c34a020..0000000 --- a/src/local-services.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { randomBytes, randomUUID } from 'node:crypto'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { BlobServiceClient } from '@azure/storage-blob'; -import { QueueClient } from '@azure/storage-queue'; -import { z } from 'zod'; -import { connectionFileStore, objectKey } from './storage.js'; -import { localJobData } from './local-job-data.js'; -import { deleteLocalJobSnapshot } from './local-job-snapshots.js'; -import { localOrigin, dabImage, sqlImage } from './local.js'; -import type { FileProcessing } from './gateway.js'; -import { run, type Run } from './process.js'; -import { readLocalSettings } from './local-settings.js'; -import { localTracing } from './local-tracing.js'; -import type { TraceParent } from './trace-contract.js'; -import type { UserIdentity } from './auth.js'; -import { maintainLocalJobs } from './local-maintenance.js'; -import { runtimeFor, functionsImage } from './workspace.mjs'; -import { verifyContainerWorkspace } from './local-ownership.js'; -import { probeReadiness } from './readiness.js'; -import { toolingAsset, toolingRoot } from './runtime-roots.js'; - -export const localFunctionsOrigin = runtimeFor().origins.functions; -const azuriteImage = 'mcr.microsoft.com/azure-storage/azurite:3.35.0@sha256:647c63a91102a9d8e8000aab803436e1fc85fbb285e7ce830a82ee5d6661cf37'; -const account = 'sqlappslocal'; -const stateSchema = z.strictObject({ key: z.string().regex(/^[A-Za-z0-9+/]{86}==$/), secret: z.string().regex(/^[a-f0-9]{64}$/) }); - -export function localServiceImages(container: string): Record { - const runtime = runtimeFor(container); - return { sql: sqlImage, data: dabImage, storage: azuriteImage, - functions: runtime.mode === 'isolated' ? new RegExp(`^${runtime.imageRepository}:[a-f0-9]{64}$`) : `${runtime.imageRepository}:local` }; -} - -export function serviceNames(container: string) { - if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(container)) throw new Error('Invalid SQL container name'); - return runtimeFor(container).names; -} - -export function storageConnection(key: string, host = '127.0.0.1') { - const ports = host === '127.0.0.1' ? runtimeFor().ports : { blob: 10000, queue: 10001 }; - return `DefaultEndpointsProtocol=http;AccountName=${account};AccountKey=${key};BlobEndpoint=http://${host}:${ports.blob}/${account};QueueEndpoint=http://${host}:${ports.queue}/${account};`; -} - -export function localServiceReadiness(connection: string, secret: string) { - const options = { retryOptions: { maxTries: 1, tryTimeoutInMs: 4_000 } }; - const blobs = BlobServiceClient.fromConnectionString(connection, options); - const queues = ['file-jobs', 'file-jobs-poison'].map(name => new QueueClient(connection, name, options)); - const input = { - user: { oid: '11111111-1111-4111-8111-111111111111', tenantId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, - input: { readiness: true }, - }; - const expected = z.strictObject({ - result: z.strictObject({ - user: z.strictObject({ oid: z.literal(input.user.oid), tenantId: z.literal(input.user.tenantId) }), - input: z.strictObject({ readiness: z.literal(true) }), - }), - }); - return async (fetcher: typeof fetch, signal: AbortSignal): Promise => { - const results = await Promise.allSettled([ - probeReadiness('blob', async () => { - await Promise.all(['files', 'traces'].map(name => - blobs.getContainerClient(name).getProperties({ abortSignal: signal }))); - }), - probeReadiness('queue', async () => { - await Promise.all(queues.map(queue => queue.getProperties({ abortSignal: signal }))); - }), - probeReadiness('functions', async () => { - const response = await fetcher(new URL('/api/echo', localFunctionsOrigin), { - method: 'POST', headers: { authorization: `Bearer ${secret}`, 'content-type': 'application/json' }, - body: JSON.stringify(input), signal, redirect: 'error', - }); - if (response.status !== 200) throw new Error(`Functions readiness returned HTTP ${response.status}`); - expected.parse(await response.json()); - }), - ]); - const failure = results.find(result => result.status === 'rejected'); - if (failure?.status === 'rejected') throw failure.reason; - }; -} - -async function credentials(container: string, create: boolean) { - const names = serviceNames(container); - try { return stateSchema.parse(JSON.parse(await readFile(names.state, 'utf8'))); } - catch (error) { - if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; - if (!create) throw new Error('Local service credentials are missing. Restore the gitignored service state alongside the existing storage volume; existing account keys are not silently reset.', { cause: error }); - await mkdir(runtimeFor(container).stateDirectory, { recursive: true }); - const state = { key: randomBytes(64).toString('base64'), secret: randomBytes(32).toString('hex') }; - await writeFile(names.state, JSON.stringify(state), { flag: 'wx', mode: 0o600 }); - return state; - } -} - -export async function ensureOwned(name: string, role: string, runner: Run = run): Promise { - const present = (await runner('docker', ['ps', '--all', '--format', '{{.Names}}', '--filter', `name=^${name}$`])).trim(); - if (!present) return false; - const label = (await runner('docker', ['inspect', '--format', '{{index .Config.Labels "sql-apps.local"}}', name])).trim(); - if (label !== role) throw new Error(`Container ${name} is not project-owned (${role}); refusing to modify it`); - const runtime = runtimeFor(); - if (runtime.mode === 'isolated') { - const image = localServiceImages(runtime.sqlContainer)[role]; - if (!image) throw new Error(`Unknown local service role ${role}`); - const ports: Record = role === 'storage' ? { '10000/tcp': runtime.ports.blob, '10001/tcp': runtime.ports.queue } : - role === 'functions' ? { '80/tcp': runtime.ports.functions } : { '5000/tcp': runtime.ports.data }; - await verifyContainerWorkspace(name, runtime, image, ports, runner); - } - return true; -} - -export async function startLocalServices(container: string, runner: Run = run): Promise { - if (process.env.NODE_ENV === 'production') throw new Error('Local services are disabled in production'); - const names = serviceNames(container); - const runtime = runtimeFor(container); - const settings = await readLocalSettings(); - const storageExists = await ensureOwned(names.storage, 'storage', runner); - const functionsExist = await ensureOwned(names.functions, 'functions', runner); - const volumeExists = (await runner('docker', ['volume', 'ls', '--filter', `name=^${names.storage}-data$`, '--format', '{{.Name}}'])).trim(); - if (volumeExists && runtime.mode === 'isolated') { - const owner = z.object({ workspace: z.string(), role: z.string() }).parse(JSON.parse(await runner('docker', - ['volume', 'inspect', `${names.storage}-data`, '--format', - '{"workspace":{{json (index .Labels "sql-apps.workspace")}},"role":{{json (index .Labels "sql-apps.local")}}}']))); - if (owner.workspace !== runtime.id || owner.role !== 'storage') throw new Error('Storage volume is not owned by this workspace; refusing to initialize it'); - } - const state = await credentials(container, !storageExists && !volumeExists); - if (!volumeExists) await runner('docker', ['volume', 'create', '--label', 'sql-apps.local=storage', ...runtime.labels, `${names.storage}-data`]); - if (!await ensureOwned(names.data, 'data', runner)) throw new Error('Start local DAB before local services'); - const networkExists = (await runner('docker', ['network', 'ls', '--filter', `name=^${names.network}$`, '--format', '{{.Name}}'])).trim(); - if (networkExists) { - const network = z.object({ Internal: z.boolean(), Labels: z.record(z.string(), z.string()) }) - .parse(JSON.parse(await runner('docker', ['network', 'inspect', '--format', '{{json .}}', names.network]))); - if (!network.Internal || network.Labels['sql-apps.local'] !== 'services' || - (runtime.mode === 'isolated' && network.Labels['sql-apps.workspace'] !== runtime.id)) throw new Error('Local worker network must be workspace-owned and internal'); - } else { - await runner('docker', ['network', 'create', '--internal', '--label', 'sql-apps.local=services', ...runtime.labels, names.network]); - } - const networks = async (name: string) => z.record(z.string(), z.object({ IPAddress: z.string() })) - .parse(JSON.parse(await runner('docker', ['inspect', '--format', '{{json .NetworkSettings.Networks}}', name]))); - if (!(await networks(names.data))[names.network]) await runner('docker', ['network', 'connect', names.network, names.data]); - if (storageExists) await runner('docker', ['rm', '--force', names.storage]); - await runner('docker', ['run', '--detach', '--name', names.storage, '--label', 'sql-apps.local=storage', - ...runtime.labels, - '--network', 'bridge', - '--publish', `127.0.0.1:${runtime.ports.blob}:10000`, '--publish', `127.0.0.1:${runtime.ports.queue}:10001`, - '--mount', `type=volume,source=${names.storage}-data,target=/data`, - '--env', 'AZURITE_ACCOUNTS', azuriteImage, 'azurite', '--blobHost', '0.0.0.0', '--queueHost', '0.0.0.0', - '--location', '/data', '--skipApiVersionCheck', '--disableProductStyleUrl', '--disableTelemetry'], - { env: { AZURITE_ACCOUNTS: `${account}:${state.key}` }, redact: [state.key] }); - await runner('docker', ['network', 'connect', names.network, names.storage]); - const connection = storageConnection(state.key); - const retryOptions = { maxTries: 1, tryTimeoutInMs: 3_000 }; - const blobs = BlobServiceClient.fromConnectionString(connection, { retryOptions }).getContainerClient('files'); - const queue = new QueueClient(connection, 'file-jobs', { retryOptions }); - let ready = false; - for (let attempt = 0; attempt < 30; attempt++) { - try { - await blobs.createIfNotExists(); await queue.createIfNotExists(); - await new QueueClient(connection, 'file-jobs-poison', { retryOptions }).createIfNotExists(); - await BlobServiceClient.fromConnectionString(connection, { retryOptions }).getContainerClient('traces').createIfNotExists(); - ready = true; break; - } - catch (error) { - if (typeof error === 'object' && error !== null && 'statusCode' in error && - typeof error.statusCode === 'number' && error.statusCode >= 400 && error.statusCode < 500) { - throw new Error(`Azurite provisioning rejected the request (${error.statusCode}); check local service credentials`, { cause: error }); - } - if (attempt === 29) throw new Error('Azurite readiness failed', { cause: error }); - await new Promise(resolve => setTimeout(resolve, 1_000)); - } - } - if (!ready) throw new Error('Azurite did not become ready'); - const image = functionsImage(runtime); - await runner('docker', ['build', '--file', toolingAsset('functions', 'local.Dockerfile'), - '--tag', image, toolingRoot]); - const address = async (name: string) => { - const ip = (await networks(name))[names.network]?.IPAddress; - if (!ip || !/^\d+\.\d+\.\d+\.\d+$/.test(ip)) throw new Error(`Container ${name} has no local worker network IPv4 address`); - return ip; - }; - const storageIp = await address(names.storage); - if (functionsExist) await runner('docker', ['rm', '--force', names.functions]); - await runner('docker', ['run', '--detach', '--name', names.functions, '--label', 'sql-apps.local=functions', - ...runtime.labels, - '--network', 'bridge', - '--publish', `127.0.0.1:${runtime.ports.functions}:80`, - '--env', 'AzureWebJobsStorage', '--env', 'LOCAL_FUNCTION_SECRET', '--env', 'LOCAL_DAB_URL', - '--env', 'LOCAL_SETTINGS', - '--env', 'FUNCTIONS_WORKER_RUNTIME=node', '--env', 'SQL_APPS_LOCAL_FUNCTIONS=true', image], - { env: { AzureWebJobsStorage: storageConnection(state.key, storageIp), LOCAL_FUNCTION_SECRET: state.secret, - LOCAL_DAB_URL: `http://${names.data}:5000`, LOCAL_SETTINGS: JSON.stringify(settings) }, redact: [state.key, state.secret] }); - await runner('docker', ['network', 'connect', names.network, names.functions]); - for (let attempt = 0; attempt < 120; attempt++) { - let response: Response | undefined; - try { - response = await fetch(`${localFunctionsOrigin}/api/echo`, { - method: 'POST', headers: { authorization: `Bearer ${state.secret}`, 'content-type': 'application/json' }, - body: JSON.stringify({ user: { oid: '11111111-1111-4111-8111-111111111111', - tenantId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, input: { readiness: true } }), - signal: AbortSignal.timeout(2_000), - }); - } catch (error) { - if (attempt === 119) throw new Error(`Local Functions failed to start; inspect docker logs ${names.functions}`, { cause: error }); - } - if (response) { - await response.arrayBuffer(); - if (response.ok) { console.log('Azurite and real local Functions ready (Blob, Queue, HTTP).'); return; } - if (![404, 503].includes(response.status)) throw new Error(`Functions readiness returned ${response.status}`); - } - await new Promise(resolve => setTimeout(resolve, 1_000)); - } - throw new Error(`Local Functions not ready; inspect docker logs ${names.functions}`); -} - -export async function stopLocalServices(container: string, runner: Run = run) { - const names = serviceNames(container); - const functionsExist = await ensureOwned(names.functions, 'functions', runner); - const storageExists = await ensureOwned(names.storage, 'storage', runner); - if (functionsExist) await runner('docker', ['rm', '--force', names.functions]); - if (storageExists) await runner('docker', ['stop', names.storage]); -} - -export async function localServiceAdapters(container: string) { - if (process.env.NODE_ENV === 'production') throw new Error('Local services are disabled in production'); - const state = await credentials(container, false); - const connection = storageConnection(state.key); - const files = connectionFileStore(connection, 'files'); - const snapshots = BlobServiceClient.fromConnectionString(connection).getContainerClient('files'); - const queue = new QueueClient(connection, 'file-jobs'); - const jobs = localJobData(localOrigin); - const settings = await readLocalSettings(); - const telemetry = settings.tracing ? localTracing(connection) : undefined; - const submit = async (identity: UserIdentity, name: string, bytes: Buffer, parent?: TraceParent, previous?: string) => { - const id = randomUUID(); - const action = async (traceParent?: TraceParent) => { - const step = async (name: string, work: () => Promise) => telemetry - ? telemetry.operation(identity, name, async (_trace, span) => { span.setAttribute('job.id', id); return work(); }, traceParent) - : work(); - const key = `${identity.tenantId}/${identity.oid}/jobs/${id}`; - await step('blob.snapshot.write', () => files.put(key, bytes)); - try { await step('sql.job.create', () => jobs.create(identity.oid, { id, filename: name, blob_key: key, ...(previous ? { parent_job_id: previous } : {}) })); } - catch (error) { await files.delete(key); throw error; } - try { - await step('queue.job.send', () => queue.sendMessage(Buffer.from(JSON.stringify({ id, oid: identity.oid, tenantId: identity.tenantId, key, - ...(traceParent ? { trace: traceParent } : {}), - })).toString('base64'))); - } catch (error) { - await jobs.update(identity.oid, id, { status: 'failed', error: 'Queue submission failed; retry the saved snapshot after storage recovery' }); - throw error; - } - return id; - }; - return telemetry ? telemetry.operation(identity, 'snapshot.sql.queue.submit', async (trace, span) => { - span.setAttribute('job.id', id); return action(trace); - }, parent) : action(); - }; - const processing: FileProcessing = { - list: identity => jobs.list(identity.oid), - async create(identity, name, parent) { - if (!settings.processing) throw Object.assign(new Error('File processing is disabled by local settings'), { statusCode: 503 }); - const file = await files.get(objectKey(identity.tenantId, identity.oid, name)); - if (!file) throw Object.assign(new Error('File not found'), { statusCode: 404 }); - return submit(identity, name, file.bytes, parent); - }, - async retry(identity, id, parent) { - if (!settings.processing || !settings.jobRetry) throw Object.assign(new Error('Job retry is disabled by local settings'), { statusCode: 503 }); - const row = await jobs.get(identity.oid, id); - if (!row) throw Object.assign(new Error('Job not found'), { statusCode: 404 }); - if (row.status !== 'failed') throw Object.assign(new Error('Only failed jobs can be retried'), { statusCode: 409 }); - const file = await files.get(`${identity.tenantId}/${identity.oid}/jobs/${id}`); - if (!file) throw Object.assign(new Error('Job snapshot is missing; upload the file and submit a new job'), { statusCode: 409 }); - return submit(identity, row.filename, file.bytes, parent, id); - }, - async delete(identity, id) { - return deleteLocalJobSnapshot(jobs, snapshots, identity.tenantId, identity.oid, id); - }, - }; - return { files, processing, functionToken: async () => state.secret, settings, - ready: localServiceReadiness(connection, state.secret), ...(telemetry ? { telemetry } : {}) }; -} - -export async function runLocalMaintenance(container: string) { - const state = await credentials(container, false); - const blobs = BlobServiceClient.fromConnectionString(storageConnection(state.key)); - return maintainLocalJobs(localJobData(localOrigin), blobs.getContainerClient('files'), blobs.getContainerClient('traces'), await readLocalSettings()); -} diff --git a/src/local-settings.ts b/src/local-settings.ts deleted file mode 100644 index d95aa59..0000000 --- a/src/local-settings.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { z } from 'zod'; -import { runtimeFor } from './workspace.mjs'; - -export const localSettingsSchema = z.strictObject({ - processing: z.boolean().default(true), - automaticProgress: z.boolean().default(true), - jobRetry: z.boolean().default(true), - tracing: z.boolean().default(true), - progressIntervalMs: z.number().int().min(1000).max(30000).default(2000), - staleJobMinutes: z.number().int().min(5).max(10080).default(60), - retentionDays: z.number().int().min(0).max(365).default(0), - traceRetentionDays: z.number().int().min(1).max(30).default(7), -}); -export type LocalSettings = z.infer; - -export async function readLocalSettings(): Promise { - if (process.env.NODE_ENV === 'production') throw new Error('Local settings are disabled in production'); - try { - return localSettingsSchema.parse(JSON.parse(await readFile(runtimeFor().settingsPath, 'utf8'))); - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return localSettingsSchema.parse({}); - throw new Error(`Invalid local settings: check ${runtimeFor().settingsPath} against local-settings.example.json`, { cause: error }); - } -} diff --git a/src/local-startup.ts b/src/local-startup.ts index 41e0c33..74fc26e 100644 --- a/src/local-startup.ts +++ b/src/local-startup.ts @@ -1,11 +1,10 @@ export interface LocalStartupActions { command: (step: 'start-sql' | 'init' | 'data', container: string) => Promise; - services: (container: string) => Promise; serve: (container: string) => Promise; } export async function startLocalApplication( - container: string, actions: LocalStartupActions, report: (message: string) => void = console.log, dataOnly = false, + container: string, actions: LocalStartupActions, report: (message: string) => void = console.log, ): Promise { const stages = [ { @@ -20,10 +19,6 @@ export async function startLocalApplication( name: 'Data API', run: () => actions.command('data', container), recovery: 'Check the workspace data container and selected DAB port reported by workspace-check. Retry data for this SQL container after resolving the conflict; never stop unrelated services.', }, - ...(!dataOnly ? [{ - name: 'Storage and Functions', run: () => actions.services(container), - recovery: 'Preserve volumes. Check downloads/builds and workspace Blob/Queue/Functions ports reported by workspace-check; retry services for the same SQL container.', - }] : []), { name: 'Browser gateway', run: () => actions.serve(container), recovery: 'Check who owns the workspace gateway port and reuse the intended app. If free, retry serve for this SQL container; choose your development user after restart.', diff --git a/src/local-tracing.ts b/src/local-tracing.ts deleted file mode 100644 index 90400fa..0000000 --- a/src/local-tracing.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { ROOT_CONTEXT, trace, TraceFlags, SpanStatusCode, type Span } from '@opentelemetry/api'; -import { BasicTracerProvider, SimpleSpanProcessor, type SpanExporter } from '@opentelemetry/sdk-trace-base'; -import { BlobServiceClient } from '@azure/storage-blob'; -import type { UserIdentity } from './auth.js'; -import { traceRecordSchema, type TraceRecord, type TraceParent } from './trace-contract.js'; - -export function localTracing(connection: string) { - const container = BlobServiceClient.fromConnectionString(connection, { - retryOptions: { maxTries: 1, tryTimeoutInMs: 3000 }, - }).getContainerClient('traces'); - const exporter: SpanExporter = { - export(spans, callback) { - void Promise.all(spans.map(async span => { - const oid = String(span.attributes['user.oid']); - const tenant = String(span.attributes['user.tenant']); - const context = span.spanContext(); - const start = span.startTime[0] * 1000 + span.startTime[1] / 1e6; - const record: TraceRecord = { - traceId: context.traceId, spanId: context.spanId, - ...(span.parentSpanContext ? { parentSpanId: span.parentSpanContext.spanId } : {}), - name: span.name, startedAt: new Date(start).toISOString(), - durationMs: span.duration[0] * 1000 + span.duration[1] / 1e6, - status: span.status.code === SpanStatusCode.ERROR ? 'error' : 'ok', - ...(typeof span.attributes['job.id'] === 'string' ? { jobId: span.attributes['job.id'] } : {}), - }; - const newestFirst = String(9999999999999 - Math.floor(start)).padStart(13, '0'); - await container.getBlockBlobClient(`${tenant}/${oid}/${newestFirst}-${context.traceId}-${context.spanId}.json`) - .uploadData(Buffer.from(JSON.stringify(record)), { blobHTTPHeaders: { blobContentType: 'application/json' } }); - })).then(() => callback({ code: 0 }), error => { - console.error('Local trace export failed', { errorType: error instanceof Error ? error.name : 'UnknownError' }); - callback({ code: 1, error: error instanceof Error ? error : new Error('Trace export failed') }); - }); - }, - async shutdown() {}, - }; - const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); - const tracer = provider.getTracer('sql-apps-local'); - return { - begin(identity: UserIdentity, name: string, parent?: TraceParent): Span { - const context = parent ? trace.setSpanContext(ROOT_CONTEXT, { ...parent, traceFlags: TraceFlags.SAMPLED, isRemote: true }) : ROOT_CONTEXT; - return tracer.startSpan(name, { attributes: { 'user.oid': identity.oid, 'user.tenant': identity.tenantId } }, context); - }, - async end(span: Span, failed = false) { - span.setStatus({ code: failed ? SpanStatusCode.ERROR : SpanStatusCode.OK }); - span.end(); - await provider.forceFlush(); - }, - async operation(identity: UserIdentity, name: string, action: (parent: TraceParent, span: Span) => Promise, parent?: TraceParent): Promise { - const span = this.begin(identity, name, parent); - try { - const result = await action(span.spanContext(), span); - await this.end(span); - return result; - } catch (error) { await this.end(span, true); throw error; } - }, - async list(identity: UserIdentity): Promise { - const names: string[] = []; - for await (const blob of container.listBlobsFlat({ prefix: `${identity.tenantId}/${identity.oid}/` })) { - if (names.length >= 100) break; - names.push(blob.name); - } - const result: TraceRecord[] = []; - for (let index = 0; index < names.length; index += 8) { - result.push(...await Promise.all(names.slice(index, index + 8).map(async name => - traceRecordSchema.parse(JSON.parse((await container.getBlockBlobClient(name).downloadToBuffer()).toString()))))); - } - return result; - }, - async shutdown() { await provider.shutdown(); }, - }; -} -export type LocalTracing = ReturnType; diff --git a/src/local.ts b/src/local.ts index 2dabdbe..a23bc18 100644 --- a/src/local.ts +++ b/src/local.ts @@ -1,11 +1,9 @@ import { readFile, unlink } from 'node:fs/promises'; import { resolve, join, sep } from 'node:path'; -import { randomBytes, randomUUID } from 'node:crypto'; +import { randomBytes } from 'node:crypto'; import { z } from 'zod'; import { saveJson, withDeploymentLock } from './deployment.js'; import type { Run } from './process.js'; -import { SqlAppsClient, ApiError } from './client.js'; -import { localJobData } from './local-job-data.js'; import { runtimeFor } from './workspace.mjs'; import { verifyContainerWorkspace } from './local-ownership.js'; import { pinnedDabImage } from './artifacts.js'; @@ -284,7 +282,7 @@ export async function publishLocalSchema(container: string, target: { { redact: [adminPassword, adminPassword.replaceAll('"', '""')] }); } -export async function initLocal(container: string, run: Run, authorized?: RoleBasedProfile): Promise { +export async function initLocal(container: string, run: Run, authorized: RoleBasedProfile): Promise { const { stateFile } = localPaths(container); await withDeploymentLock(stateFile, async () => { await verifyLocal(container, run); @@ -304,10 +302,9 @@ IF SUSER_ID(N'${login}') IS NULL CREATE LOGIN [${login}] WITH PASSWORD = N'${sta project: projectPath('sql', 'database.sqlproj'), output: projectPath('dist', 'sql'), }, run); - const grants = authorized ? roleBasedProcedureGrants( + const grants = roleBasedProcedureGrants( JSON.parse(await readFile(projectPath('dab', 'dab-config.json'), 'utf8')), authorized, login, - ) : `GRANT SELECT, INSERT, UPDATE, DELETE ON dbo.FileJobs TO [${login}]; -GRANT VIEW DEFINITION ON dbo.FileJobs TO [${login}];`; + ); try { await databaseQuery(state, ` IF DATABASE_PRINCIPAL_ID(N'${login}') IS NULL CREATE USER [${login}] FOR LOGIN [${login}]; @@ -316,7 +313,7 @@ ${grants} } catch (error) { throw new Error('Local app-user setup failed. Older preview images may reject login mapping; use start-sql with a new container name and the current azure-sql/db-dev image. Runtime will not fall back to sa.', { cause: error }); } - if (!authorized) await testLocalSql(state, run); + await testLocalSql(state, run); }); } @@ -343,31 +340,20 @@ export function localPrincipal(oid: string, scope = 'access_as_user', role = 'au })).toString('base64'); } -export async function localDabConfig(authorized?: RoleBasedProfile): Promise { +export async function localDabConfig(authorized: RoleBasedProfile): Promise { const config = JSON.parse(await readFile(projectPath('dab', 'dab-config.json'), 'utf8')); - if (authorized) validateRoleBasedDab(config, authorized); + validateRoleBasedDab(config, authorized); config.runtime.host = { mode: 'development', authentication: { provider: 'AppService' } }; - if (authorized) return config; - config.entities.FileJob = { - source: { object: 'dbo.FileJobs', type: 'table' }, rest: { enabled: true }, graphql: { enabled: true }, - permissions: [ - { role: 'authenticated', actions: [{ action: 'read', fields: { exclude: ['blob_key'] }, - policy: { database: '@item.owner_oid eq @claims.oid' } }] }, - { role: 'processor', actions: ['create', 'read', 'update', 'delete'] }, - ], - }; return config; } -export async function startLocalData(container: string, run: Run, authorized?: RoleBasedProfile): Promise { +export async function startLocalData(container: string, run: Run, authorized: RoleBasedProfile): Promise { const runtime = runtimeFor(container); const state = await readLocal(container); const { dataContainer, configFile: path } = localPaths(container); const config = await localDabConfig(authorized); - if (authorized) { - await verifyLocal(container, run); - await databaseQuery(state, roleBasedProcedureGrants(config, authorized, login), run, true); - } else await testLocalSql(state, run); + await verifyLocal(container, run); + await databaseQuery(state, roleBasedProcedureGrants(config, authorized, login), run, true); await saveJson(path, config); const networks = z.record(z.string(), z.object({ IPAddress: z.ipv4() })) .parse(JSON.parse(await run('docker', ['inspect', container, '--format', '{{json .NetworkSettings.Networks}}']))); @@ -384,18 +370,6 @@ export async function startLocalData(container: string, run: Run, authorized?: R '--mount', `type=bind,source=${path},target=/App/dab-config.json,readonly`, dabImage], { env: { SQL_CONNECTION_STRING: localConnection(state.password, `${network[1].IPAddress},1433`) }, redact: [state.password] }); - const servicesNetwork = runtime.names.network; - const servicesNetworkExists = (await run('docker', - ['network', 'ls', '--filter', `name=^${servicesNetwork}$`, '--format', '{{.Name}}'])).trim(); - if (servicesNetworkExists) { - const details = z.object({ Internal: z.literal(true), Labels: z.record(z.string(), z.string()) }) - .parse(JSON.parse(await run('docker', ['network', 'inspect', '--format', '{{json .}}', servicesNetwork]))); - if (details.Labels['sql-apps.local'] !== 'services' || - (runtime.mode === 'isolated' && details.Labels['sql-apps.workspace'] !== runtime.id)) { - throw new Error('Local worker network is not owned by this workspace; refusing to attach DAB'); - } - await run('docker', ['network', 'connect', servicesNetwork, dataContainer]); - } for (let attempt = 0; attempt < 30; attempt++) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(new Error('Local DAB health check timed out')), 2_000); @@ -423,105 +397,22 @@ async function verifyOwnedData(container: string, run: Run): Promise { } } -export async function testLocalData(fetcher: typeof fetch = fetch): Promise { - const owner = randomUUID(); - const other = randomUUID(); - const client = (oid: string) => new SqlAppsClient({ - baseUrl: localOrigin, getAccessToken: async () => 'local-claim-simulation', - fetch: async (url, init) => { - const headers = new Headers(init?.headers); - headers.delete('authorization'); - headers.set('x-ms-client-principal', localPrincipal(oid)); - const request = new URL(String(url)); - if (request.pathname === '/api/FileJob' && (!init?.method || init.method === 'GET') && !request.search) { - request.searchParams.set('$first', '1'); - } - return fetcher(request, { ...init, headers }); - }, - }); - const alice = client(owner); - const bob = client(other); - const id = randomUUID(); - const secondId = randomUUID(); - const fixtures: string[] = []; - const jobs = localJobData(localOrigin, fetcher); - let failure: unknown; - try { - await jobs.create(owner, { id, filename: 'acceptance.txt', blob_key: 'acceptance-only' }); - fixtures.push(id); - await jobs.create(owner, { id: secondId, filename: 'pagination.txt', blob_key: 'acceptance-only' }); - fixtures.push(secondId); - async function list(api: SqlAppsClient) { - const ids: string[] = []; - let next: string | undefined = '/api/FileJob'; - const seen = new Set(); - while (next) { - const url = new URL(next, localOrigin); - if (url.origin !== localOrigin || url.pathname !== '/api/FileJob' || url.username || url.password || url.hash || seen.has(url.href)) { - throw new Error('Invalid acceptance pagination'); - } - seen.add(url.href); - const body = z.object({ value: z.array(z.object({ id: z.string(), blob_key: z.never().optional() })), nextLink: z.string().optional() }) - .parse(await (await api.request(url.href)).json()); - ids.push(...body.value.map(row => row.id.toLowerCase())); - next = body.nextLink; - } - return ids; - } - const ownRows = await list(alice); - if (![id, secondId].every(value => ownRows.includes(value))) throw new Error('Owner REST pagination lost a row'); - if ((await list(bob)).includes(id)) throw new Error('Cross-user REST row exposure'); - await jobs.update(owner, id, { status: 'completed' }); - if ((await jobs.get(owner, id))?.status !== 'completed') throw new Error('Owner update failed'); - for (const [api, shouldSee] of [[alice, true], [bob, false]] as const) { - const body = z.object({ - data: z.object({ fileJobs: z.object({ items: z.array(z.object({ id: z.string() })) }) }), - }).parse(await (await api.request('/graphql', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ query: `{ fileJobs(filter: { id: { eq: "${id}" } }) { items { id filename status } } }` }) })).json()); - if (body.data.fileJobs.items.some(row => row.id.toLowerCase() === id) !== shouldSee) throw new Error('GraphQL row isolation failed'); - } - await assertLocalDenied(() => alice.request('/api/FileJob', { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id: randomUUID(), filename: 'forbidden', owner_oid: other }), - })); - await assertLocalDenied(() => alice.request(`/api/FileJob/id/${id}`, { - method: 'PATCH', headers: { 'content-type': 'application/json', 'if-match': '*' }, - body: JSON.stringify({ owner_oid: other }), - })); - const anonymous = await fetcher(`${localOrigin}/api/FileJob`, { signal: AbortSignal.timeout(5_000) }); - if (![401, 403].includes(anonymous.status)) throw new Error(`Anonymous DAB request returned ${anonymous.status}`); - } catch (error) { - failure = error; - throw error; - } finally { - const cleanup = await Promise.allSettled(fixtures.map(fixture => jobs.delete(owner, fixture))); - const errors = cleanup.filter((result): result is PromiseRejectedResult => result.status === 'rejected').map(result => result.reason); - if (errors.length) throw new AggregateError([...(failure ? [failure] : []), ...errors], 'Local acceptance fixture cleanup failed'); - } -} - -async function assertLocalDenied(action: () => Promise): Promise { - try { await action(); } - catch (error) { - if (error instanceof ApiError && [400, 403, 404].includes(error.status)) return; - throw error; - } - throw new Error('Forbidden local DAB mutation succeeded'); -} - export async function localCommand(command: string, container: string, run: Run, authorized?: RoleBasedProfile): Promise { + const profile = () => { + if (!authorized) throw new Error(`${command} needs role-based-data.json`); + return authorized; + }; if (command === 'start-sql') await startLocalSql(container, run); else if (command === 'recover-sql') await recoverLocalSql(container, run); else if (command === 'verify') await verifyLocal(container, run); - else if (command === 'init') await initLocal(container, run, authorized); + else if (command === 'init') await initLocal(container, run, profile()); else if (command === 'test') await testLocalSql(await readLocal(container), run); else if (command === 'app-check') { const result = await databaseQuery(await readLocal(container), await readFile(projectPath('sql', 'local', 'application-acceptance.sql'), 'utf8'), run, true); if (!result.includes('CLEAN APPLICATION DATABASE PASSED')) throw new Error('Clean application database acceptance did not report success'); } - else if (command === 'data') await startLocalData(container, run, authorized); - else if (command === 'api-test') await testLocalData(); + else if (command === 'data') await startLocalData(container, run, profile()); else if (command === 'stop') { const data = localPaths(container).dataContainer; await verifyOwnedData(data, run); diff --git a/src/maintenance.ts b/src/maintenance.ts deleted file mode 100644 index d122201..0000000 --- a/src/maintenance.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { type DeploymentConfig } from './config.js'; -import { type DeploymentState } from './deployment.js'; -import { type Run } from './process.js'; -import { build } from 'esbuild'; -import { imageDigest } from './artifacts.js'; - -export async function deployStatic( - config: DeploymentConfig, state: DeploymentState, run: Run, - buildAssets: () => Promise = async () => { - await build({ entryPoints: ['src/web/app.ts'], bundle: true, format: 'esm', outfile: 'public/app.js' }); - }, -): Promise { - const currentImage = (await run('az', ['containerapp', 'show', '--name', state.outputs.gatewayName, - '--resource-group', config.resourceGroup, '--subscription', config.subscriptionId, - '--query', 'properties.template.containers[0].image', '-o', 'tsv'])).trim(); - if (!currentImage.startsWith(`${config.registryServer}/`) || !/@sha256:[a-f0-9]{64}$/.test(currentImage)) { - throw new Error('Static-only deployment requires the current gateway image to be pinned to an ACR digest'); - } - if (!config.gatewayImage.startsWith(`${config.registryServer}/`) || config.gatewayImage.includes('@')) { - throw new Error('Set gatewayImage to a new version tag for the static-only build output'); - } - await buildAssets(); - await run('az', ['acr', 'build', '--subscription', config.subscriptionId, '--registry', config.registryServer.split('.')[0]!, - '--image', config.gatewayImage.slice(config.registryServer.length + 1), '--file', 'infra/static.Dockerfile', - '--build-arg', `RUNTIME_IMAGE=${currentImage}`, '.']); - const pinned = { ...config, gatewayImage: await imageDigest(config.gatewayImage, config, run) }; - await updateImage('gateway', pinned, state, run); - return pinned; -} - -export async function updateImage( - target: 'gateway' | 'functions', config: DeploymentConfig, state: DeploymentState, run: Run, -): Promise { - if (target === 'functions' && config.profile === 'role-based-data') throw new Error('Functions excluded by role-based-data profile'); - if (!state.outputs.gatewayUrl) throw new Error('Complete a full deployment before image-only updates'); - if (target === 'gateway') { - await run('az', ['containerapp', 'update', '--name', state.outputs.gatewayName, '--resource-group', config.resourceGroup, - '--subscription', config.subscriptionId, '--image', config.gatewayImage, '-o', 'none']); - } else { - await run('az', ['functionapp', 'config', 'container', 'set', '--name', state.outputs.functionsName, - '--resource-group', config.resourceGroup, '--subscription', config.subscriptionId, - '--image', config.functionsImage, '--registry-server', `https://${config.registryServer}`, '-o', 'none']); - } -} - -export async function setSecret( - name: string, value: string, config: DeploymentConfig, state: DeploymentState, run: Run, -): Promise { - if (config.profile === 'role-based-data') throw new Error('Key Vault excluded by role-based-data profile'); - if (!/^[a-zA-Z0-9-]{1,127}$/.test(name)) throw new Error('Invalid Key Vault secret name'); - if (!value) throw new Error('Secret value must not be empty'); - await run('az', ['keyvault', 'secret', 'set', '--vault-name', state.outputs.vaultName, - '--subscription', config.subscriptionId, '--name', name, '--value', value, '-o', 'none']); -} diff --git a/src/process.ts b/src/process.ts index a64a23b..8d8a778 100644 --- a/src/process.ts +++ b/src/process.ts @@ -25,14 +25,6 @@ function execute(command: string, args: readonly string[], env = process.env, op child.once('close', (code, signal) => { if (code !== 0) { let message = options.sensitiveOutput ? 'sensitive command output omitted' : stderr.trim() || stdout.trim(); - for (const arg of args) { - if (arg.startsWith('/AccessToken:')) message = message.replaceAll(arg.slice('/AccessToken:'.length), '[REDACTED]'); - } - const secretIndex = args.indexOf('--value'); - if (secretIndex >= 0 && args[secretIndex + 1]) message = message.replaceAll(args[secretIndex + 1]!, '[REDACTED]'); - for (const arg of args) { - if (arg.startsWith('/TargetPassword:')) message = message.replaceAll(arg.slice('/TargetPassword:'.length), '[REDACTED]'); - } for (const value of options.redact ?? []) { if (value) message = message.replaceAll(value, '[REDACTED]'); } diff --git a/src/project-contract.mjs b/src/project-contract.mjs index 697977c..cad708b 100644 --- a/src/project-contract.mjs +++ b/src/project-contract.mjs @@ -32,9 +32,11 @@ export async function parseProjectManifest(value) { if (typeof value.id !== 'string' || !uuid.test(value.id)) invalid('Project id must be a UUID.'); if (typeof value.name !== 'string' || !value.name.trim()) invalid('Project name must not be empty.'); if (typeof value.slug !== 'string' || !slug.test(value.slug)) invalid('Project slug is invalid.'); - if (!['data', 'files-jobs'].includes(value.starter)) invalid('Project starter is invalid.'); - const expectedProfile = value.starter === 'data' ? 'role-based-data' : 'foundation'; - if (value.profile !== expectedProfile) invalid('Project starter and profile are incompatible.'); + if (value.starter === 'files-jobs') { + invalid('The files-jobs starter (foundation profile) was removed in SQL Apps 0.2.0; keep this project on 0.1.x tooling.'); + } + if (value.starter !== 'data') invalid('Project starter is invalid.'); + if (value.profile !== 'role-based-data') invalid('Project starter and profile are incompatible.'); if (!Number.isInteger(value.scaffoldVersion) || value.scaffoldVersion < 1) { invalid('Project scaffoldVersion must be a positive integer.'); } diff --git a/src/readiness.ts b/src/readiness.ts index 91d6a6f..f9c2433 100644 --- a/src/readiness.ts +++ b/src/readiness.ts @@ -1,4 +1,4 @@ -export type ReadinessDependency = 'data' | 'blob' | 'queue' | 'functions'; +export type ReadinessDependency = 'data'; export class DependencyReadinessError extends Error { constructor(readonly dependency: ReadinessDependency, cause: unknown) { @@ -15,4 +15,4 @@ export async function probeReadiness( } catch (cause) { throw new DependencyReadinessError(dependency, cause); } -} +} \ No newline at end of file diff --git a/src/role-based-cost.ts b/src/role-based-cost.ts deleted file mode 100644 index d175da7..0000000 --- a/src/role-based-cost.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { z } from 'zod'; - -const schema = z.strictObject({ - version: z.literal(1), profile: z.literal('role-based-data'), - intent: z.enum(['zero-azure-spend', 'review-paid-costs']), - acknowledgeFixedCharges: z.boolean(), -}).refine(value => value.intent !== 'zero-azure-spend' || !value.acknowledgeFixedCharges, - 'Zero-spend intent cannot acknowledge paid charges'); - -export function reviewRoleBasedCost(input: unknown) { - const config = schema.parse(input); - const permitted = config.intent === 'review-paid-costs' && config.acknowledgeFixedCharges; - return { - version: 1, profile: config.profile, intent: config.intent, - review: { canProceedToWhatIf: permitted, - status: permitted ? 'ready-for-target-cost-review' : 'blocked-by-paid-infrastructure', - nextAction: permitted ? 'Review current regional/account prices and obtain separate target/what-if approval.' : - 'Stay local or separately review paid SQL, registry and private-network charges; no paid fallback is authorized.' }, - estimatedMonthlyCost: null, pricingStatus: 'not-quoted-region-currency-and-account-review-required', - deployment: { authorized: false, ready: false }, - resources: [ - { name: 'Azure SQL', sku: 'S0', minCapacity: 10, billing: 'paid provisioned database, not free-offer SQL' }, - { name: 'ACR', sku: 'not-verified', billing: 'configured dedicated registry; review its actual tier, storage and build charges' }, - { name: 'Gateway and DAB', sku: 'Container Apps Consumption', minReplicas: 1, maxReplicas: 2, - each: { vCpu: 0.5, memoryGiB: 1 }, billing: 'both services have always-on allocation; shared grants are not a cap' }, - { name: 'Private networking', billing: 'SQL private endpoint, DNS and managed VNet infrastructure charges' }, - { name: 'Log Analytics', billing: 'ingestion and retention charges' }, - ], - excluded: ['Functions', 'Blob/Queue/Table storage', 'Key Vault', 'Functions/storage/vault private endpoints'], - cleanup: 'Review/export application data and remove only explicitly owned resources with separate approval; discovery is not reuse consent.', - monitoring: 'Review actual/forecast costs in app and managed infrastructure groups. Budgets are delayed alerts, not hard caps.', - references: { - sql: 'https://azure.microsoft.com/pricing/details/azure-sql-database/single/', - containers: 'https://azure.microsoft.com/pricing/details/container-apps/', - registry: 'https://azure.microsoft.com/pricing/details/container-registry/', - network: 'https://azure.microsoft.com/pricing/details/private-link/', - logs: 'https://azure.microsoft.com/pricing/details/monitor/', - }, - }; -} diff --git a/src/server.ts b/src/server.ts index 217ca18..adcb292 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,28 +1,13 @@ -import { DefaultAzureCredential } from '@azure/identity'; import { entraVerifier } from './auth.js'; import { loadRuntimeConfig, portFromEnvironment } from './config.js'; import { createGateway } from './gateway.js'; -import { blobStore } from './storage.js'; const config = loadRuntimeConfig(process.env); -const credential = new DefaultAzureCredential( - process.env.AZURE_CLIENT_ID ? { managedIdentityClientId: process.env.AZURE_CLIENT_ID } : {}, -); const app = await createGateway(config, { verifyUser: entraVerifier(config.tenantId, config.apiClientId), - files: config.profile === 'role-based-data' ? { - put: async () => { throw new Error('Files excluded by role-based-data profile'); }, - get: async () => { throw new Error('Files excluded by role-based-data profile'); }, - delete: async () => { throw new Error('Files excluded by role-based-data profile'); }, - } : blobStore(config.blobAccountUrl, config.blobContainer, credential), fetch, - async functionToken() { - const token = await credential.getToken(`api://${config.apiClientId}/.default`); - if (!token) throw new Error('No managed identity token available for function invocation'); - return token.token; - }, }); await app.listen({ port: portFromEnvironment(process.env.PORT), host: '0.0.0.0' }); for (const signal of ['SIGINT', 'SIGTERM'] as const) { process.once(signal, () => { void app.close(); }); -} +} \ No newline at end of file diff --git a/src/smoke.ts b/src/smoke.ts deleted file mode 100644 index bab51d3..0000000 --- a/src/smoke.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { SqlAppsClient, ApiError } from './client.js'; -import { z } from 'zod'; - -const identitySchema = z.object({ oid: z.string().min(1), tenantId: z.string().min(1) }); -const restPageSchema = z.object({ - value: z.array(z.object({ id: z.string(), owner_oid: z.string().optional(), blob_key: z.never().optional() })), -}); -const graphqlPageSchema = z.object({ - errors: z.never().optional(), - data: z.object({ fileJobs: z.object({ items: z.array(z.object({ id: z.string() })) }) }), -}); - -async function readData(api: SqlAppsClient, oid: string): Promise { - const rest = restPageSchema.parse(await (await api.request('/api/FileJob?$first=5')).json()); - if (rest.value.some(row => row.owner_oid !== undefined && row.owner_oid.toLowerCase() !== oid.toLowerCase())) { - throw new Error('REST data path returned another user\'s row'); - } - graphqlPageSchema.parse(await (await api.request('/graphql', { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ query: '{ fileJobs(first: 5) { items { id filename status } } }' }), - })).json()); -} - -export async function smoke( - origin: string, userToken: string, otherUserToken: string, fetcher: typeof fetch = fetch, functionsUrl?: string, -): Promise { - const user = new SqlAppsClient({ baseUrl: origin, getAccessToken: async () => userToken, fetch: fetcher }); - const other = new SqlAppsClient({ baseUrl: origin, getAccessToken: async () => otherUserToken, fetch: fetcher }); - const who = identitySchema.parse(await (await user.request('/auth/me')).json()); - const otherWho = identitySchema.parse(await (await other.request('/auth/me')).json()); - if (who.oid === otherWho.oid || who.tenantId !== otherWho.tenantId) throw new Error('Two different verified users in the configured tenant are required'); - const id = randomUUID(); - const filename = `acceptance-${id}.txt`; - let uploaded = false; - const failures: unknown[] = []; - try { - await readData(user, who.oid); - await readData(other, otherWho.oid); - await user.upload(filename, Buffer.from('private acceptance data')); - uploaded = true; - if (Buffer.from(await user.download(filename)).toString() !== 'private acceptance data') throw new Error('File roundtrip payload mismatch'); - for (const operation of [() => other.download(filename), () => other.deleteFile(filename)]) { - await assertDenied(operation); - } - const result = z.object({ result: z.object({ user: identitySchema, input: z.object({ acceptance: z.string() }) }) }) - .parse(await user.echo({ acceptance: id })); - if (result.result.user.oid !== who.oid || result.result.user.tenantId !== who.tenantId || result.result.input.acceptance !== id) { - throw new Error('Function user context or payload mismatch'); - } - if (functionsUrl) { - const direct = await fetcher(new URL('/api/echo', functionsUrl), { - method: 'POST', headers: { authorization: `Bearer ${userToken}`, 'content-type': 'application/json' }, - body: '{}', signal: AbortSignal.timeout(10_000), - }); - if (direct.status !== 401) throw new Error('Direct function user-token access was not rejected'); - } - if ((await fetcher(new URL('/auth/me', origin))).status !== 401) throw new Error('Anonymous API access was not rejected'); - for (const path of ['/api/FileJob', '/graphql']) { - const anonymous = await fetcher(new URL(path, origin), { - method: path === '/graphql' ? 'POST' : 'GET', - ...(path === '/graphql' ? { headers: { 'content-type': 'application/json' }, body: '{"query":"{ fileJobs { items { id } } }"}' } : {}), - signal: AbortSignal.timeout(10_000), - }); - if (![401, 403].includes(anonymous.status)) throw new Error(`Anonymous ${path} request returned ${anonymous.status}`); - } - } catch (error) { failures.push(error); } - finally { - if (uploaded) { - try { await user.deleteFile(filename); } catch (error) { failures.push(error); } - } - } - if (failures.length) throw new AggregateError(failures, 'Authenticated acceptance or fixture cleanup failed'); -} - -async function assertDenied(operation: () => Promise) { - try { await operation(); } - catch (error) { if (error instanceof ApiError && [403, 404].includes(error.status)) return; throw error; } - throw new Error('Cross-user operation unexpectedly succeeded'); -} diff --git a/src/storage.ts b/src/storage.ts deleted file mode 100644 index d2b67ef..0000000 --- a/src/storage.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { BlobServiceClient } from '@azure/storage-blob'; -import type { TokenCredential } from '@azure/identity'; - -export interface StoredFile { - bytes: Buffer; - contentType: string; -} - -export interface FilePage { - files: { name: string; size: number }[]; - continuationToken?: string; -} - -export interface FileStore { - list?(prefix: string, options: { continuationToken?: string; maxPageSize: number }): Promise; - put(key: string, bytes: Buffer): Promise; - get(key: string): Promise; - delete(key: string): Promise; -} - -export function objectKey(tenantId: string, oid: string, name: string): string { - if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(name) || name.includes('..')) { - throw new Error('File names must be a single safe path segment'); - } - return `${tenantId}/${oid}/${name}`; -} - -export function blobStore(accountUrl: string, container: string, credential: TokenCredential): FileStore { - const client = new BlobServiceClient(accountUrl, credential).getContainerClient(container); - return containerFileStore(client); -} - -export function connectionFileStore(connection: string, container: string): FileStore { - return containerFileStore(BlobServiceClient.fromConnectionString(connection).getContainerClient(container)); -} - -function containerFileStore(client: ReturnType): FileStore { - return { - async list(prefix, options) { - const files: { name: string; size: number }[] = []; - const page = await client.listBlobsByHierarchy('/', { prefix }).byPage(options).next(); - if (page.done) return { files }; - for (const blob of page.value.segment.blobItems) { - const name = blob.name.slice(prefix.length); - if (!name || name.includes('/')) throw new Error('Blob hierarchy returned an invalid file name'); - if (typeof blob.properties.contentLength !== 'number') throw new Error('Blob listing omitted file size'); - files.push({ name, size: blob.properties.contentLength }); - } - return { files, ...(page.value.continuationToken ? { continuationToken: page.value.continuationToken } : {}) }; - }, - async put(key, bytes) { - await client.getBlockBlobClient(key).uploadData(bytes, { - blobHTTPHeaders: { blobContentType: 'application/octet-stream' }, - }); - }, - async get(key) { - try { - const blob = client.getBlockBlobClient(key); - return { bytes: await blob.downloadToBuffer(), contentType: 'application/octet-stream' }; - } catch (error) { - if (typeof error === 'object' && error !== null && 'statusCode' in error && error.statusCode === 404) { - return undefined; - } - throw error; - } - }, - async delete(key) { - return (await client.getBlockBlobClient(key).deleteIfExists()).succeeded; - }, - }; -} diff --git a/src/trace-contract.ts b/src/trace-contract.ts deleted file mode 100644 index e5c2e82..0000000 --- a/src/trace-contract.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { z } from 'zod'; - -export const traceRecordSchema = z.object({ - traceId: z.string().regex(/^[a-f0-9]{32}$/), - spanId: z.string().regex(/^[a-f0-9]{16}$/), - parentSpanId: z.string().regex(/^[a-f0-9]{16}$/).optional(), - name: z.string(), - startedAt: z.string(), - durationMs: z.number().nonnegative(), - status: z.enum(['ok', 'error']), - jobId: z.string().uuid().optional(), -}); -export type TraceRecord = z.infer; -export interface TraceParent { traceId: string; spanId: string } diff --git a/src/web/app.ts b/src/web/app.ts index c7a3a16..16e302d 100644 --- a/src/web/app.ts +++ b/src/web/app.ts @@ -3,7 +3,6 @@ import { parseBrowserConfig } from '../browser-config.js'; import { parseCurrentUser } from '../access.js'; import { prepareAuthentication, SessionExpiredError, type BrowserAuthentication } from './session.js'; import { renderAuthentication, authenticationErrorMessage, type AuthState } from './auth-view.js'; -import { watchJobProgress } from './job-progress.js'; const element = (id: string) => { const value = document.getElementById(id); @@ -24,9 +23,7 @@ function showAuth(state: AuthState) { renderAuthentication(authElements, authMode, state); } function clearWorkspace() { - for (const id of ['files', 'jobs', 'traces']) element(id).replaceChildren(); - element('function-result').textContent = ''; - element('file-form').reset(); + element('data-result').textContent = ''; } function report(error: unknown): boolean { if (error instanceof SessionExpiredError || (error instanceof ApiError && error.status === 401)) { @@ -79,168 +76,25 @@ async function main() { showAuth({ kind: 'authorized', name: session.name }); const signedIn = config.mode === 'local' ? 'Local preview is open.' : `Signed in as ${session.name}`; status.textContent = signedIn; - element('file-form').hidden = !config.capabilities.files; - element('echo').hidden = !config.capabilities.functions; - element('service-notice').hidden = config.capabilities.files && config.capabilities.functions; - element('file-workspace').hidden = !config.capabilities.files; - element('job-workspace').hidden = !config.capabilities.processing; - element('trace-workspace').hidden = !config.capabilities.tracing; - element('job-progress').textContent = config.capabilities.automaticProgress ? 'Job progress updates automatically.' : 'Use Refresh jobs to update progress.'; let busy = false; - let fileContinuationToken: string | undefined; - let displayedFiles = 0; - async function operation(message: string, action: () => Promise) { + async function checkData() { if (busy || !canUseApp()) return; busy = true; - const controls = document.querySelectorAll('#workspace button, #workspace input'); - controls.forEach(control => { control.disabled = true; }); - authElements.logout.disabled = true; - status.textContent = message; - try { await action(); if (canUseApp()) status.textContent = signedIn; } - catch (error) { report(error); } + element('check-data').disabled = true; + status.textContent = 'Checking data...'; + try { + const rows = await client.readiness(config.readinessPath); + if (!canUseApp()) return; + element('data-result').textContent = JSON.stringify(rows, null, 2); + status.textContent = signedIn; + } catch (error) { report(error); } finally { busy = false; - document.querySelectorAll('#workspace button, #workspace input') - .forEach(control => { control.disabled = false; }); - authElements.logout.disabled = false; - } - } - async function refreshFiles(append = false) { - if (!canUseApp() || !config.capabilities.files) return; - const page = await client.listFiles(append ? fileContinuationToken : undefined); - if (!canUseApp()) return; - if (!append) { - displayedFiles = 0; - element('files').replaceChildren(); - } - displayedFiles += page.files.length; - element('no-files').hidden = displayedFiles !== 0; - for (const file of page.files) { - const item = document.createElement('li'); - item.append(`${file.name} (${file.size} bytes) `); - const download = document.createElement('button'); - download.textContent = 'Download'; - download.onclick = () => { void operation('Downloading file...', async () => { - const bytes = await client.download(file.name); - if (!canUseApp()) return; - const url = URL.createObjectURL(new Blob([bytes])); - const anchor = document.createElement('a'); - anchor.href = url; anchor.download = file.name; - document.body.append(anchor); anchor.click(); anchor.remove(); - setTimeout(() => URL.revokeObjectURL(url), 30_000); - }); }; - const remove = document.createElement('button'); - remove.textContent = 'Delete file'; - remove.onclick = () => { void operation('Deleting file...', async () => { await client.deleteFile(file.name); await refreshFiles(); }); }; - item.append(download, remove); - if (config.capabilities.processing && config.capabilities.jobSubmission !== false) { - const process = document.createElement('button'); - process.textContent = 'Process file'; - process.onclick = () => { void operation('Submitting processing job...', async () => { - await client.processFile(file.name); await refreshJobs(); - }); }; - item.append(process); - } - item.querySelectorAll('button').forEach(button => { button.disabled = busy; }); - element('files').append(item); - } - fileContinuationToken = page.continuationToken; - element('load-more-files').hidden = fileContinuationToken === undefined; - } - let lastJobs = ''; - let jobRefresh = 0; - async function refreshJobs(signal?: AbortSignal) { - if (!canUseApp() || !config.capabilities.processing) return; - const generation = ++jobRefresh; - const jobs = await client.listJobs(signal); - if (signal?.aborted || generation !== jobRefresh || !canUseApp()) return; - const serialized = JSON.stringify(jobs); - if (serialized === lastJobs) return; - lastJobs = serialized; - element('no-jobs').hidden = jobs.length !== 0; - element('jobs').replaceChildren(); - for (const job of jobs) { - const item = document.createElement('li'); - item.textContent = `${job.filename}: ${job.status}`; - item.append(` | Job ${job.id}`); - if (job.created_at) item.append(` | Created ${job.created_at} UTC`); - if (job.parent_job_id) item.append(` | Retry of ${job.parent_job_id}`); - if (job.status === 'completed') item.append(` | ${job.byte_count} bytes | ${job.line_count} text lines | SHA-256 ${job.sha256}`); - if (job.error) item.append(` | ${job.error}`); - if (job.status === 'completed' || job.status === 'failed') { - if (job.status === 'failed' && config.capabilities.jobRetry) { - const retry = document.createElement('button'); - retry.textContent = 'Retry snapshot'; - retry.onclick = () => { void operation('Retrying saved snapshot...', async () => { - await client.retryJob(job.id); await refreshJobs(); - }); }; - item.append(retry); - } - const remove = document.createElement('button'); - remove.textContent = 'Delete job'; - remove.onclick = () => { void operation('Deleting job...', async () => { await client.deleteJob(job.id); await refreshJobs(); }); }; - item.append(remove); - } - item.querySelectorAll('button').forEach(button => { button.disabled = busy; }); - element('jobs').append(item); - } - } - async function refreshTraces() { - if (!canUseApp() || !config.capabilities.tracing) return; - const traces = await client.listTraces(); - if (!canUseApp()) return; - element('traces').replaceChildren(); - for (const trace of traces) { - const item = document.createElement('li'); - item.textContent = `${trace.name} | ${trace.status} | ${trace.durationMs.toFixed(1)} ms | ${trace.startedAt} | Trace ${trace.traceId} | Span ${trace.spanId}`; - if (trace.parentSpanId) item.append(` | Parent ${trace.parentSpanId}`); - if (trace.jobId) item.append(` | Job ${trace.jobId}`); - element('traces').append(item); + element('check-data').disabled = false; } - if (!traces.length) element('traces').textContent = 'No traces yet. Upload or process a file to record activity.'; - } - element('refresh-traces').onclick = () => { void operation('Loading traces...', refreshTraces); }; - element('refresh-files').onclick = () => { void operation('Loading files...', refreshFiles); }; - element('load-more-files').onclick = () => { - void operation('Loading more files...', () => refreshFiles(true)); - }; - element('refresh-jobs').onclick = () => { void operation('Loading jobs...', refreshJobs); }; - element('file-form').onsubmit = event => { - event.preventDefault(); - const file = element('file').files?.[0]; - if (!file) return; - if (file.size > 4 * 1024 * 1024) { report(new Error('Files must be at most 4 MiB')); return; } - void operation('Uploading file...', async () => { - await client.upload(file.name, file); element('file-form').reset(); await refreshFiles(); - }); - }; - element('echo').onclick = () => { - void operation('Invoking Azure Function...', async () => { - const result = await client.echo({ message: 'Hello Azure' }); - if (!canUseApp()) return; - element('function-result').textContent = JSON.stringify(result); - }); - }; - await operation('Loading workspace...', async () => { await refreshFiles(); await refreshJobs(); }); - if (canUseApp() && config.capabilities.processing && config.capabilities.automaticProgress) { - const stop = watchJobProgress({ - intervalMs: config.capabilities.progressIntervalMs ?? 2000, - available: () => canUseApp() && !busy && document.visibilityState === 'visible', - refresh: refreshJobs, - report: error => { - if (error) { - if (!report(error)) return; - element('job-progress').textContent = error instanceof SessionExpiredError || (error instanceof ApiError && error.status === 401) - ? authenticationErrorMessage(error, authMode) - : error instanceof ApiError && error.status === 403 ? 'Reading job progress is not permitted.' - : 'Automatic progress is unavailable; retrying with backoff. Check local services.'; - } else if (canUseApp()) { - element('job-progress').textContent = 'Job progress updates automatically.'; - } - }, - }); - window.addEventListener('pagehide', stop, { once: true }); } + element('check-data').onclick = () => { void checkData(); }; + await checkData(); } void main().catch(report); diff --git a/src/web/job-progress.ts b/src/web/job-progress.ts deleted file mode 100644 index 37ab65d..0000000 --- a/src/web/job-progress.ts +++ /dev/null @@ -1,26 +0,0 @@ -export function watchJobProgress(options: { - refresh: (signal: AbortSignal) => Promise; - available: () => boolean; - report: (error?: unknown) => void; - intervalMs: number; -}) { - const controller = new AbortController(); - let delay = options.intervalMs; - let timer: ReturnType | undefined; - async function tick() { - if (controller.signal.aborted) return; - if (options.available()) { - try { - await options.refresh(controller.signal); - if (!controller.signal.aborted) options.report(); - delay = options.intervalMs; - } catch (error) { - if (!controller.signal.aborted) options.report(error); - delay = Math.min(30000, delay * 2); - } - } - if (!controller.signal.aborted) timer = setTimeout(() => { void tick(); }, delay); - } - timer = setTimeout(() => { void tick(); }, delay); - return () => { controller.abort(); if (timer) clearTimeout(timer); }; -} diff --git a/src/workspace.d.mts b/src/workspace.d.mts index 8936528..4500033 100644 --- a/src/workspace.d.mts +++ b/src/workspace.d.mts @@ -16,19 +16,15 @@ export interface WorkspaceRuntime { login: string; ownsSqlName: boolean; stateDirectory: string; - ports: { gateway: number; data: number; functions: number; blob: number; queue: number; sql: number | null }; - origins: { app: string; data: string; functions: string }; - names: { storage: string; functions: string; data: string; network: string; sqlNetwork: string; - state: string; sqlState: string; dataConfig: string }; - settingsPath: string; - imageRepository: string; + ports: { gateway: number; data: number; sql: number | null }; + origins: { app: string; data: string }; + names: { data: string; sqlNetwork: string; sqlState: string; dataConfig: string }; labels: string[]; } export function workspaceFile(root?: string): string; export function readWorkspace(root?: string): WorkspaceDescriptor | null; export function describeWorkspace(root: string, selection: number | 'legacy'): WorkspaceDescriptor; export function runtimeFor(container?: string, root?: string, proposed?: WorkspaceDescriptor): WorkspaceRuntime; -export function functionsImage(runtime?: WorkspaceRuntime): string; export function portState(port: number): Promise; export function proposeWorkspace(root?: string, probe?: (port: number) => Promise): Promise<{ selected?: WorkspaceDescriptor; diff --git a/src/workspace.mjs b/src/workspace.mjs index 51d1dd0..505f0f4 100644 --- a/src/workspace.mjs +++ b/src/workspace.mjs @@ -5,14 +5,16 @@ import { join } from 'node:path'; import { createServer } from 'node:net'; const hash = value => createHash('sha256').update(value).digest('hex').slice(0, 12); -const keys = ['gateway', 'data', 'functions', 'blob', 'queue', 'sql']; -const legacyPorts = { gateway: 18080, data: 15000, functions: 17071, blob: 10000, queue: 10001, sql: null }; +// Offsets inside the 10-port block stay fixed so existing isolated workspaces keep their SQL port. +const offsets = { gateway: 0, data: 1, sql: 5 }; +const keys = Object.keys(offsets); +const legacyPorts = { gateway: 18080, data: 15000, sql: null }; const validBlock = value => Number.isInteger(value) && value >= 20000 && value <= 60000 && value % 10 === 0; export const workspaceFile = (root = process.cwd()) => join(root, '.sql-apps', 'workspace.json'); export function readWorkspace(root = process.cwd()) { - const home = realpathSync(root); + const home = realpathSync.native(root); let text; try { text = readFileSync(workspaceFile(home), 'utf8'); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } @@ -33,12 +35,12 @@ export function readWorkspace(root = process.cwd()) { // A proposed descriptor lets consent plans compute the same contracts before workspace-init writes it. export function describeWorkspace(root, selection) { - const home = realpathSync(root); + const home = realpathSync.native(root); return selection === 'legacy' ? { version: 1, mode: 'legacy', home, id: hash(home), basePort: null } : candidate(home, selection); } export function runtimeFor(container, root = process.cwd(), proposed) { - const home = realpathSync(root); + const home = realpathSync.native(root); const descriptor = proposed ?? readWorkspace(home); const isolated = descriptor?.mode === 'isolated'; const id = hash(home); @@ -47,7 +49,7 @@ export function runtimeFor(container, root = process.cwd(), proposed) { if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(sqlContainer)) throw new Error('Invalid SQL container name'); const suffix = hash(isolated ? `${id}/${sqlContainer}` : sqlContainer); const stateDirectory = isolated ? join(home, '.sql-apps', 'workspaces', id) : join(home, '.sql-apps'); - const ports = isolated ? Object.fromEntries(keys.map((key, index) => [key, descriptor.basePort + index])) : { ...legacyPorts }; + const ports = isolated ? Object.fromEntries(keys.map(key => [key, descriptor.basePort + offsets[key]])) : { ...legacyPorts }; const prefix = isolated ? `sql-apps-${id}` : 'sql-apps'; return { home, id, mode: descriptor?.mode ?? 'legacy-unselected', defaultSql, sqlContainer, @@ -56,33 +58,17 @@ export function runtimeFor(container, root = process.cwd(), proposed) { ownsSqlName: isolated && sqlContainer === defaultSql, stateDirectory, ports, origins: { app: `http://127.0.0.1:${ports.gateway}`, data: `http://127.0.0.1:${ports.data}`, - functions: `http://127.0.0.1:${ports.functions}`, }, names: { - storage: `${prefix}-storage-${suffix}`, functions: `${prefix}-functions-${suffix}`, - data: `${prefix}-local-data-${suffix}`, network: `${prefix}-services-${suffix}`, + data: `${prefix}-local-data-${suffix}`, sqlNetwork: `${prefix}-sql-network`, - state: join(stateDirectory, `services-${suffix}.json`), sqlState: join(stateDirectory, `local-${suffix}.json`), dataConfig: join(stateDirectory, `dab-local-${suffix}.json`), }, - settingsPath: join(stateDirectory, 'local-settings.json'), - imageRepository: isolated ? `${prefix}-functions` : 'sql-apps-functions', labels: isolated ? ['--label', `sql-apps.workspace=${id}`] : [], }; } -export function functionsImage(runtime = runtimeFor()) { - if (runtime.mode !== 'isolated') return `${runtime.imageRepository}:local`; - let metadata; - try { metadata = JSON.parse(readFileSync(join(runtime.home, 'dist', 'build-provenance.json'), 'utf8')); } - catch { throw new Error('Verified build provenance is required before building a workspace Functions image. Run npm run build here.'); } - if (metadata.version !== 1 || metadata.home !== runtime.home || !/^[a-f0-9]{64}$/.test(metadata.artifactFingerprint)) { - throw new Error('Workspace Functions build provenance is invalid; rebuild in the selected checkout.'); - } - return `${runtime.imageRepository}:${metadata.artifactFingerprint}`; -} - export async function portState(port) { return new Promise(done => { const server = createServer(); @@ -101,12 +87,12 @@ function candidate(home, basePort) { } async function inspectPorts(descriptor, probe) { - return Promise.all(keys.map(async (name, index) => ({ name, port: descriptor.basePort + index, - state: await probe(descriptor.basePort + index) }))); + return Promise.all(keys.map(async name => ({ name, port: descriptor.basePort + offsets[name], + state: await probe(descriptor.basePort + offsets[name]) }))); } export async function proposeWorkspace(root = process.cwd(), probe = portState) { - const home = realpathSync(root); + const home = realpathSync.native(root); const existing = readWorkspace(home); if (existing) return { selected: existing, scope: 'Existing selection; resource ownership still needs verification before startup.' }; const basePort = 20000 + (parseInt(hash(home).slice(0, 8), 16) % 4000) * 10; @@ -126,7 +112,7 @@ export async function proposeWorkspace(root = process.cwd(), probe = portState) } export async function initializeWorkspace(root = process.cwd(), selection, probe = portState) { - const home = realpathSync(root); + const home = realpathSync.native(root); if (readWorkspace(home)) throw new Error('Workspace already selected; review existing state instead of overwriting its descriptor.'); const descriptor = describeWorkspace(home, selection); if (descriptor.mode === 'isolated') { diff --git a/tests/application.test.ts b/tests/application.test.ts index 0f12893..60382c8 100644 --- a/tests/application.test.ts +++ b/tests/application.test.ts @@ -13,14 +13,13 @@ const checker = resolve('scripts/check-application.mjs'); test('clean application validation rejects leftovers in each actual delivery surface', async t => { const root = await mkdtemp(join(tmpdir(), 'sql-apps-clean-')); t.after(() => rm(root, { recursive: true, force: true })); - for (const dir of ['src', 'public', 'dab', 'sql', 'tests', 'dist/tests', 'dist/src', 'dist/functions', 'functions']) await mkdir(join(root, dir), { recursive: true }); + for (const dir of ['src', 'public', 'dab', 'sql', 'tests', 'dist/tests', 'dist/src']) await mkdir(join(root, dir), { recursive: true }); await writeFile(join(root, 'application.json'), JSON.stringify({ name: 'test-app', selectedExamples: [] })); await writeFile(join(root, 'public/app.js'), 'console.log("orders");'); - await mkdir(join(root, 'dist/functions/src'), { recursive: true }); - for (const file of ['dist/src/client.js', 'dist/src/server.js', 'dist/functions/src/index.js']) { + for (const file of ['dist/src/client.js', 'dist/src/server.js']) { await writeFile(join(root, file), ''); } - for (const file of ['Dockerfile', 'functions/Dockerfile', 'functions/local.Dockerfile']) { + for (const file of ['Dockerfile', 'dab/Dockerfile']) { await writeFile(join(root, file), 'COPY --from=build /app/dist/src ./dist/src'); } assert.match(await run(process.execPath, [checker, root]), /Clean application passed/); @@ -39,7 +38,7 @@ test('clean application validation rejects leftovers in each actual delivery sur ['sql/table.sql', 'CREATE TABLE dbo.Todos (id int);'], ['dist/src/client.js', 'class TodoExampleClient {}'], ['dist/src/component.mjs', 'class TodoExampleClient {}'], - ['dist/functions/worker.js', 'import "../examples/todo/client.js";'], + ['dist/src/worker.js', 'import "../examples/todo/client.js";'], ]; for (const [path, content] of leftovers) { await writeFile(join(root, path), content); @@ -79,10 +78,10 @@ const fixture = 'import { application } from "../examples/todo/application.js";' 'COPY --from=build /app/dist/* /app/dist', 'COPY --chown=node:node \\\n tests ./tests', ]) { - await writeFile(join(root, 'functions/local.Dockerfile'), copy); + await writeFile(join(root, 'dab/Dockerfile'), copy); await assert.rejects(run(process.execPath, [checker, root]), /whole build/); } - await writeFile(join(root, 'functions/local.Dockerfile'), 'COPY --from=build /app/dist/src ./dist/src'); + await writeFile(join(root, 'dab/Dockerfile'), 'COPY --from=build /app/dist/src ./dist/src'); await rm(join(root, 'dist/src/server.js')); await assert.rejects(run(process.execPath, [checker, root]), /ENOENT/); }); @@ -141,34 +140,35 @@ test('API cleanliness requires missing entities, not auth/backend errors, and al assert.equal(called, false); }); -test('foundation gateway acceptance validates raw public job shape before client parsing can hide fields', async () => { - for (const exposePrivateKey of [false, true]) { +test('local gateway acceptance checks role access and always cleans up sessions', async () => { + const profile = { profile: 'role-based-data' as const, requiredRole: 'AppUser', readinessPath: '/api/AppReady' }; + for (const scenario of [{ bobStatus: 403, succeeds: true }, { bobStatus: 200, succeeds: false }]) { const identities = new Map(); const cleaned: string[] = []; + let issued = 0; const fetcher: typeof fetch = async (input, options) => { const url = new URL(String(input)); - const token = new Headers(options?.headers).get('authorization')?.slice('Bearer '.length); + const token = new Headers(options?.headers).get('authorization')?.split(' ')[1]; if (url.pathname === '/local/session' && options?.method === 'POST') { const { user } = JSON.parse(String(options.body)); - const issued = `acceptance-${user}`; - identities.set(issued, user); - return Response.json({ token: issued }); + const session = `acceptance-session-${++issued}-`.padEnd(40, 'x'); + identities.set(session, user); + return Response.json({ token: session }); } if (options?.method === 'DELETE') { assert.ok(token); cleaned.push(token); return new Response(null, { status: 204 }); } - if (url.pathname === '/auth/me') return token ? - Response.json({ oid: identities.get(token) }) : new Response(null, { status: 401 }); - return Response.json({ jobs: [{ - id: '11111111-1111-4111-8111-111111111111', filename: 'acceptance.txt', status: 'queued', - sha256: null, byte_count: null, line_count: null, error: null, - ...(exposePrivateKey ? { blob_key: 'must-not-be-public' } : {}), - }] }); + const user = token ? identities.get(token) : undefined; + if (!user) return new Response(null, { status: 401 }); + if (url.pathname === '/auth/me') return Response.json({ oid: user }); + assert.equal(url.pathname, profile.readinessPath); + const alice = user === '11111111-1111-4111-8111-111111111111'; + return alice ? Response.json({ value: [{ ready: 1 }] }) : new Response(null, { status: scenario.bobStatus }); }; - if (exposePrivateKey) await assert.rejects(testLocalApp(fetcher), /Local gateway acceptance/); - else await testLocalApp(fetcher); - assert.equal(cleaned.length, exposePrivateKey ? 1 : 2); + if (scenario.succeeds) await testLocalApp(profile, fetcher); + else await assert.rejects(testLocalApp(profile, fetcher), /Local gateway acceptance/); + assert.equal(cleaned.length, 2); } }); diff --git a/tests/architecture-catalog.test.ts b/tests/architecture-catalog.test.ts deleted file mode 100644 index a1b104d..0000000 --- a/tests/architecture-catalog.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { - architectureCatalog, - mandatoryArchitectureModules, -} from '../src/architecture-catalog.js'; -import { - validateModuleCatalog, - type ReadonlyArchitectureModuleCatalog, -} from '../src/architecture-module.js'; - -const expectedContracts = [ - { - id: 'azure-sql-free', - provides: ['sql'], - requiresModules: [], - requiresCapabilities: [], - }, - { - id: 'background-jobs', - provides: ['background-jobs'], - requiresModules: ['node-api', 'managed-identity'], - requiresCapabilities: [], - }, - { - id: 'container-apps-dab-runtime', - provides: ['dab-runtime'], - requiresModules: ['managed-identity', 'container-images'], - requiresCapabilities: [], - }, - { - id: 'container-apps-node-runtime', - provides: ['node-runtime'], - requiresModules: ['managed-identity', 'container-images'], - requiresCapabilities: [], - }, - { - id: 'container-images', - provides: ['container-images'], - requiresModules: [], - requiresCapabilities: [], - }, - { - id: 'dab', - provides: ['data-api'], - requiresModules: ['azure-sql-free'], - requiresCapabilities: ['dab-runtime'], - }, - { - id: 'entra-identity', - provides: ['entra-auth'], - requiresModules: ['node-api'], - requiresCapabilities: [], - }, - { - id: 'files', - provides: ['files'], - requiresModules: ['node-api', 'managed-identity'], - requiresCapabilities: [], - }, - { - id: 'log-analytics', - provides: ['operational-observability'], - requiresModules: [], - requiresCapabilities: [], - }, - { - id: 'managed-identity', - provides: ['managed-identity'], - requiresModules: [], - requiresCapabilities: [], - }, - { - id: 'node-api', - provides: ['application-api'], - requiresModules: [], - requiresCapabilities: ['node-runtime'], - }, - { - id: 'static-web', - provides: ['static-web'], - requiresModules: [], - requiresCapabilities: [], - }, - { - id: 'visitor-session-api', - provides: ['visitor-sessions'], - requiresModules: ['node-api'], - requiresCapabilities: [], - }, -] as const; - -test('mandatory architecture modules are exactly the reviewed SQL and DAB core', () => { - assert.deepEqual(mandatoryArchitectureModules, ['azure-sql-free', 'dab']); -}); - -test('catalog has the exact reviewed module IDs and dependency contracts', () => { - const actualContracts = [...architectureCatalog.values()].map(module => ({ - id: module.id, - provides: module.provides, - requiresModules: module.requiresModules, - requiresCapabilities: module.requiresCapabilities, - })); - - assert.deepEqual(actualContracts, expectedContracts); -}); - -test('free SQL uses the exact reviewed SKU and policy metadata', () => { - const sql = architectureCatalog.get('azure-sql-free'); - - assert.ok(sql); - assert.deepEqual(sql.resources, [ - { - type: 'Microsoft.Sql/servers', - purpose: 'Provides the logical server for the free Azure SQL Database', - }, - { - type: 'Microsoft.Sql/servers/databases', - sku: 'GP_S_Gen5_2', - purpose: 'Stores relational data with free enrollment and no paid fallback', - }, - ]); - assert.ok(sql.guarantees.includes('no-paid-sql-fallback')); - assert.ok(sql.acceptanceChecks.includes('verify-sql-free-enrollment')); -}); - -test('catalog contains no paid SQL or bill-overage module', () => { - for (const id of architectureCatalog.keys()) { - assert.doesNotMatch(id, /paid-sql|bill-overage/); - } -}); - -test('resource metadata includes required parents and identical shared resources', () => { - const dabRuntime = architectureCatalog.get('container-apps-dab-runtime'); - const nodeRuntime = architectureCatalog.get('container-apps-node-runtime'); - const files = architectureCatalog.get('files'); - const jobs = architectureCatalog.get('background-jobs'); - - assert.ok(dabRuntime); - assert.ok(nodeRuntime); - assert.ok(files); - assert.ok(jobs); - assert.deepEqual(dabRuntime.resources[0], nodeRuntime.resources[0]); - assert.equal(dabRuntime.resources[0]?.type, 'Microsoft.App/managedEnvironments'); - assert.deepEqual(files.resources[0], jobs.resources[0]); - assert.equal(files.resources[0]?.type, 'Microsoft.Storage/storageAccounts'); -}); - -test('surface equals the complete resource footprint for every module', () => { - for (const module of architectureCatalog.values()) { - assert.equal(module.surface, module.resources.length, `${module.id} surface`); - } -}); - -test('every descriptor has reviewed metadata without claiming deployment', () => { - for (const module of architectureCatalog.values()) { - assert.ok(module.acceptanceChecks.length > 0, `${module.id} acceptance check`); - for (const resource of module.resources) { - assert.match(resource.type, /^[A-Za-z][A-Za-z0-9.]+\/[A-Za-z][A-Za-z0-9/]*$/); - assert.ok(resource.purpose.length > 0); - assert.doesNotMatch(resource.purpose, /\bdeploy(?:ed|able|ment)?\b/i); - } - } -}); - -test('exported catalog validates and has deterministic ID ordering', () => { - const validated = validateModuleCatalog([...architectureCatalog.values()]); - const ids = [...architectureCatalog.keys()]; - - assert.deepEqual(ids, [...ids].sort()); - assert.deepEqual([...validated.entries()], [...architectureCatalog.entries()]); -}); - -test('exported catalog is read-only at its type-facing API', () => { - const readonlyCatalog: ReadonlyArchitectureModuleCatalog = architectureCatalog; - type CatalogHasSet = 'set' extends keyof typeof architectureCatalog ? true : false; - type CatalogHasDelete = 'delete' extends keyof typeof architectureCatalog ? true : false; - type CatalogHasClear = 'clear' extends keyof typeof architectureCatalog ? true : false; - const mutatorsExposed: [CatalogHasSet, CatalogHasDelete, CatalogHasClear] = - [false, false, false]; - - assert.equal(readonlyCatalog.size, expectedContracts.length); - assert.deepEqual(mutatorsExposed, [false, false, false]); -}); - -test('exported catalog and descriptor metadata reject runtime mutation', () => { - type CatalogMutators = { - set: (key: string, value: unknown) => unknown; - delete: (key: string) => boolean; - clear: () => void; - }; - const runtimeCatalog = architectureCatalog as unknown as Partial; - const sql = architectureCatalog.get('azure-sql-free'); - assert.ok(sql); - - assert.equal(runtimeCatalog.set, undefined); - assert.equal(runtimeCatalog.delete, undefined); - assert.equal(runtimeCatalog.clear, undefined); - assert.throws(() => { - (sql as { id: string }).id = 'changed'; - }, TypeError); - assert.throws(() => (sql.requiresModules as string[]).push('managed-identity'), TypeError); - assert.throws( - () => (sql.resources as Array<{ type: string; purpose: string }>).push({ - type: 'Microsoft.Sql/servers/firewallRules', - purpose: 'Changed', - }), - TypeError, - ); - assert.throws( - () => { - (sql.resources[0] as { purpose: string }).purpose = 'Changed'; - }, - TypeError, - ); - assert.deepEqual(sql.requiresModules, []); - assert.equal( - sql.resources[0]?.purpose, - 'Provides the logical server for the free Azure SQL Database', - ); -}); diff --git a/tests/architecture-cli.test.ts b/tests/architecture-cli.test.ts deleted file mode 100644 index 3386bc0..0000000 --- a/tests/architecture-cli.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import assert from 'node:assert/strict'; -import { existsSync } from 'node:fs'; -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { pathToFileURL } from 'node:url'; -import { test } from 'node:test'; -import { join, resolve } from 'node:path'; -import { run } from '../src/process.js'; - -const cli = resolve('dist/src/cli.js'); -const example = resolve('azure-architecture-intent.example.json'); - -test('architecture-plan compiles the example as pretty review-only JSON', async () => { - const output = await run(process.execPath, [cli, 'architecture-plan', example]); - const plan = JSON.parse(output); - - assert.equal(plan.authorized, false); - assert.equal(plan.deployable, false); - assert.deepEqual(plan.mandatoryCore, ['azure-sql-free', 'dab']); - assert.match(plan.approval, /^[a-f0-9]{12}$/); - assert.equal(plan.version, 1); - assert.equal(output, `${JSON.stringify(plan, null, 2)}\n`); -}); - -test('architecture-plan rejects intent containing unknown resources', async t => { - const directory = resolve('.sql-apps', `architecture-cli-invalid-${process.pid}`); - t.after(() => rm(directory, { recursive: true, force: true })); - await mkdir(directory, { recursive: true }); - const path = join(directory, 'invalid.json'); - const intent = JSON.parse(await readFile(example, 'utf8')); - await writeFile(path, JSON.stringify({ ...intent, resources: ['unknown-resource'] })); - - await assert.rejects( - run(process.execPath, [cli, 'architecture-plan', path]), - /resources/, - ); -}); - -test('architecture-plan requires exactly one intent path', async () => { - await assert.rejects( - run(process.execPath, [cli, 'architecture-plan']), - /Usage: architecture-plan /, - ); - await assert.rejects( - run(process.execPath, [cli, 'architecture-plan', example, 'extra']), - /Usage: architecture-plan /, - ); -}); - -test('help describes architecture-plan as offline read-only review, not approval', async () => { - const output = await run(process.execPath, [cli, 'help']); - - assert.match(output, /architecture-plan/); - assert.match(output, /offline.*read-only.*no Azure writes/i); - assert.match(output, /no Azure calls/i); - assert.match(output, /not deployment approval/i); -}); - -test('architecture-plan neither attempts Azure CLI execution nor mutates its input', async t => { - const directory = resolve('.sql-apps', `architecture-cli-offline-${process.pid}`); - const marker = join(directory, 'az-attempted'); - const preload = join(directory, 'reject-az.mjs'); - t.after(() => rm(directory, { recursive: true, force: true })); - await mkdir(directory, { recursive: true }); - await writeFile(preload, ` - import childProcess from 'node:child_process'; - import { writeFileSync } from 'node:fs'; - import { syncBuiltinESMExports } from 'node:module'; - const originalSpawn = childProcess.spawn; - childProcess.spawn = function(command, args, options) { - if (command === 'az' || (command === 'where.exe' && args?.[0] === 'az')) { - writeFileSync(${JSON.stringify(marker)}, 'attempted'); - throw new Error('architecture-plan attempted Azure CLI execution'); - } - return originalSpawn.call(this, command, args, options); - }; - syncBuiltinESMExports(); - `); - const before = await readFile(example, 'utf8'); - - await run(process.execPath, [cli, 'architecture-plan', example], { - env: { NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` }, - }); - - assert.equal(existsSync(marker), false); - assert.equal(await readFile(example, 'utf8'), before); -}); diff --git a/tests/architecture-compiler.test.ts b/tests/architecture-compiler.test.ts deleted file mode 100644 index e291f9e..0000000 --- a/tests/architecture-compiler.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { - compileArchitecture, - type CompiledArchitecturePlan, -} from '../src/architecture-compiler.js'; -import { createReadonlyModuleCatalog } from '../src/architecture-module.js'; - -const fingerprint = 'a'.repeat(64); - -function intent( - capabilities: string[], - guarantees = ['synthetic-data-only'], - growth = ['add-operational-observability'], -): unknown { - return { - version: 1, - briefFingerprint: fingerprint, - capabilities, - guarantees, - optimization: 'simplest-validated-start', - growth, - }; -} - -function descriptor( - id: string, - provides: string[], - options: { - surface?: number; - requiresModules?: string[]; - requiresCapabilities?: string[]; - incompatibleModules?: string[]; - resources?: Array<{ type: string; sku?: string; purpose: string }>; - } = {}, -) { - return { - id, - version: 1, - surface: options.surface ?? options.resources?.length ?? 0, - provides, - requiresCapabilities: options.requiresCapabilities ?? [], - requiresModules: options.requiresModules ?? [], - incompatibleModules: options.incompatibleModules ?? [], - guarantees: [`guarantee-${id}`], - resources: options.resources ?? [], - acceptanceChecks: [`check-${id}`], - growthTransitions: [], - }; -} - -test('compilation is invariant to requested capability order', () => { - const forward = compileArchitecture(intent([ - 'static-web', - 'application-api', - 'visitor-sessions', - ])); - const reversed = compileArchitecture(intent([ - 'visitor-sessions', - 'application-api', - 'static-web', - ])); - - assert.deepEqual(reversed, forward); -}); - -test('compiles the exact mandatory SQL and DAB core with its runtime closure', () => { - const plan = compileArchitecture(intent(['static-web'])); - - assert.deepEqual(plan.mandatoryCore, ['azure-sql-free', 'dab']); - assert.ok(plan.modules.includes('azure-sql-free')); - assert.ok(plan.modules.includes('dab')); - assert.ok(plan.modules.includes('container-apps-dab-runtime')); - assert.equal(plan.version, 1); - assert.equal(plan.optimization, 'simplest-validated-start'); -}); - -test('visitor plans include visitor sessions without unrelated optional modules', () => { - const plan = compileArchitecture(intent([ - 'static-web', - 'application-api', - 'visitor-sessions', - ])); - - assert.ok(plan.modules.includes('visitor-session-api')); - assert.ok(!plan.modules.includes('entra-identity')); - assert.ok(!plan.modules.includes('log-analytics')); -}); - -test('selects optional identity, files, and logs only when requested', () => { - const basic = compileArchitecture(intent(['static-web'])); - const expanded = compileArchitecture(intent([ - 'static-web', - 'entra-auth', - 'files', - 'operational-observability', - ])); - - for (const module of ['entra-identity', 'files', 'log-analytics']) { - assert.ok(!basic.modules.includes(module), `${module} absent from basic plan`); - assert.ok(expanded.modules.includes(module), `${module} present in expanded plan`); - } -}); - -test('throws an actionable error for unsupported server-rendered capability', () => { - assert.throws( - () => compileArchitecture(intent(['server-rendered'])), - /No supported module provides server-rendered/, - ); -}); - -test('emits review-only flags, explicit limitations, and a canonical approval', () => { - const plan: CompiledArchitecturePlan = compileArchitecture(intent(['static-web'])); - - assert.equal(plan.authorized, false); - assert.equal(plan.deployable, false); - assert.match(plan.approval, /^[a-f0-9]{12}$/); - assert.ok(plan.limitations.some(value => /Azure eligibility unverified/i.test(value))); - assert.ok(plan.limitations.some(value => /metadata-only/i.test(value))); - assert.ok(plan.limitations.some(value => /costs not quoted/i.test(value))); - assert.ok(plan.limitations.some(value => /no Azure writes authorized/i.test(value))); -}); - -test('searches all compositions and ranks by module count before surface and IDs', () => { - const catalog = createReadonlyModuleCatalog([ - descriptor('azure-sql-free', ['sql']), - descriptor('dab', ['data-api']), - descriptor('api-only', ['api-helper'], { surface: 0 }), - descriptor('shared-helper', ['shared-helper'], { surface: 8 }), - descriptor('a-api-provider', ['application-api'], { - requiresModules: ['api-only'], - }), - descriptor('z-api-provider', ['application-api'], { - requiresModules: ['shared-helper'], - }), - descriptor('files-provider', ['files'], { - requiresModules: ['shared-helper'], - }), - ]); - - const plan = compileArchitecture(intent(['application-api', 'files']), catalog); - - assert.ok(plan.modules.includes('z-api-provider')); - assert.ok(!plan.modules.includes('a-api-provider')); - assert.deepEqual(plan.modules, [...plan.modules].sort()); -}); - -test('uses surface then lexicographic IDs to break equal module-count ties', () => { - const lowerSurface = createReadonlyModuleCatalog([ - descriptor('azure-sql-free', ['sql']), - descriptor('dab', ['data-api']), - descriptor('a-provider', ['static-web'], { surface: 4 }), - descriptor('z-provider', ['static-web'], { surface: 1 }), - ]); - const lexical = createReadonlyModuleCatalog([ - descriptor('azure-sql-free', ['sql']), - descriptor('dab', ['data-api']), - descriptor('a-provider', ['static-web'], { surface: 1 }), - descriptor('z-provider', ['static-web'], { surface: 1 }), - ]); - - assert.ok(compileArchitecture(intent(['static-web']), lowerSurface).modules.includes('z-provider')); - assert.ok(compileArchitecture(intent(['static-web']), lexical).modules.includes('a-provider')); -}); - -test('rejects impossible compositions with an error naming incompatible modules', () => { - const catalog = createReadonlyModuleCatalog([ - descriptor('azure-sql-free', ['sql']), - descriptor('dab', ['data-api']), - descriptor('blocked-provider', ['static-web'], { - incompatibleModules: ['dab'], - }), - ]); - - assert.throws( - () => compileArchitecture(intent(['static-web']), catalog), - error => error instanceof Error - && error.message.includes('blocked-provider') - && error.message.includes('dab'), - ); -}); - -test('deduplicates shared resources with stable attribution without losing distinct resources', () => { - const shared = { - type: 'Microsoft.Example/shared', - sku: 'Free', - purpose: 'Shared purpose', - }; - const catalog = createReadonlyModuleCatalog([ - descriptor('azure-sql-free', ['sql'], { - resources: [shared], - }), - descriptor('dab', ['data-api'], { - resources: [shared], - }), - descriptor('web-provider', ['static-web'], { - resources: [ - shared, - { - type: 'Microsoft.Example/shared', - sku: 'Free', - purpose: 'Distinct purpose', - }, - ], - }), - ]); - - const plan = compileArchitecture(intent(['static-web']), catalog); - - assert.deepEqual(plan.resources, [ - { - type: 'Microsoft.Example/shared', - sku: 'Free', - purpose: 'Distinct purpose', - module: 'web-provider', - }, - { - type: 'Microsoft.Example/shared', - sku: 'Free', - purpose: 'Shared purpose', - module: 'azure-sql-free', - }, - ]); -}); diff --git a/tests/architecture-contract.test.ts b/tests/architecture-contract.test.ts deleted file mode 100644 index 1f0b335..0000000 --- a/tests/architecture-contract.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { - applicationBriefSchema, - architectureIntentSchema, - briefFingerprint, - canonical, - type ApplicationBrief, -} from '../src/architecture-contract.js'; - -const validBrief: ApplicationBrief = { - version: 1, - audience: 'public', - access: 'visitor-session', - frontend: 'static', - api: 'application', - data: 'synthetic', - files: false, - backgroundWork: false, - availability: 'proof-of-concept', - observability: 'basic', - growthPriority: 'simplest-start', - acceptanceActions: ['Create an item and see it after reload'], -}; - -test('applicationBriefSchema accepts a strict service-agnostic application brief', () => { - assert.deepEqual(applicationBriefSchema.parse(validBrief), validBrief); - assert.equal(applicationBriefSchema.safeParse({ ...validBrief, hosting: 'app-service' }).success, false); -}); - -test('applicationBriefSchema requires nonempty acceptance actions', () => { - assert.equal(applicationBriefSchema.safeParse({ ...validBrief, acceptanceActions: [] }).success, false); - assert.equal(applicationBriefSchema.safeParse({ ...validBrief, acceptanceActions: [' '] }).success, false); -}); - -test('canonical recursively sorts object keys while preserving array order', () => { - assert.equal(canonical({ z: 1, nested: { b: 2, a: 1 }, list: [{ y: 2, x: 1 }, 'last'] }), - '{"list":[{"x":1,"y":2},"last"],"nested":{"a":1,"b":2},"z":1}'); -}); - -test('canonical sorts punctuation and non-ASCII object keys by locale-independent ordinal order', () => { - assert.equal(canonical({ 'éclair': 4, zebra: 3, '中': 5, _meta: 2, '!tag': 1 }), - '{"!tag":1,"_meta":2,"zebra":3,"éclair":4,"中":5}'); -}); - -test('briefFingerprint is a stable lowercase SHA-256 digest', () => { - const reordered = { - acceptanceActions: validBrief.acceptanceActions, - growthPriority: validBrief.growthPriority, - observability: validBrief.observability, - availability: validBrief.availability, - backgroundWork: validBrief.backgroundWork, - files: validBrief.files, - data: validBrief.data, - api: validBrief.api, - frontend: validBrief.frontend, - access: validBrief.access, - audience: validBrief.audience, - version: validBrief.version, - }; - const fingerprint = briefFingerprint(validBrief); - assert.match(fingerprint, /^[a-f0-9]{64}$/); - assert.equal(briefFingerprint(reordered), fingerprint); -}); - -test('architectureIntentSchema accepts strict, unique architecture intent', () => { - const intent = { - version: 1, - briefFingerprint: briefFingerprint(validBrief), - capabilities: ['static-web', 'application-api', 'visitor-sessions'], - guarantees: ['synthetic-data-only', 'same-origin-browser'], - optimization: 'simplest-validated-start', - growth: ['add-operational-observability'], - }; - assert.deepEqual(architectureIntentSchema.parse(intent), intent); - assert.equal(architectureIntentSchema.safeParse({ ...intent, resources: ['app-service'] }).success, false); -}); - -test('architectureIntentSchema rejects duplicate capabilities', () => { - const result = architectureIntentSchema.safeParse({ - version: 1, - briefFingerprint: briefFingerprint(validBrief), - capabilities: ['static-web', 'static-web'], - guarantees: ['synthetic-data-only'], - optimization: 'simplest-validated-start', - growth: [], - }); - assert.equal(result.success, false); - if (!result.success) assert.deepEqual(result.error.issues[0]?.path, ['capabilities']); -}); - -test('applicationBriefSchema rejects anonymous access to real data at the data field', () => { - const result = applicationBriefSchema.safeParse({ ...validBrief, access: 'anonymous', data: 'real' }); - assert.equal(result.success, false); - if (!result.success) { - assert.ok(result.error.issues.some(issue => issue.code === 'custom' && issue.path.join('.') === 'data')); - } -}); - -test('applicationBriefSchema rejects server-rendered frontends with a DAB-only API at the api field', () => { - const result = applicationBriefSchema.safeParse({ ...validBrief, frontend: 'server-rendered', api: 'dab-only' }); - assert.equal(result.success, false); - if (!result.success) { - assert.ok(result.error.issues.some(issue => issue.code === 'custom' && issue.path.join('.') === 'api')); - } -}); diff --git a/tests/architecture-migration.test.ts b/tests/architecture-migration.test.ts deleted file mode 100644 index 8351cb3..0000000 --- a/tests/architecture-migration.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import { test } from 'node:test'; -import { type ArchitectureIntent } from '../src/architecture-contract.js'; -import { compileArchitecture } from '../src/architecture-compiler.js'; - -const authenticatedIntent: ArchitectureIntent = { - version: 1, - briefFingerprint: 'a'.repeat(64), - capabilities: ['static-web', 'application-api', 'entra-auth'], - guarantees: ['authenticated-users', 'private-data-path'], - optimization: 'simplest-validated-start', - growth: ['add-operational-observability'], -}; - -test('legacy role-based deployment configuration is not architecture intent', async () => { - const legacyConfiguration = JSON.parse( - await readFile('azure-role-based.example.json', 'utf8'), - ); - - assert.throws( - () => compileArchitecture(legacyConfiguration), - (error: unknown) => { - assert.ok( - typeof error === 'object' && - error !== null && - 'issues' in error && - Array.isArray(error.issues), - ); - assert.ok( - error.issues.some( - issue => - typeof issue === 'object' && - issue !== null && - 'code' in issue && - issue.code === 'unrecognized_keys', - ), - ); - return true; - }, - ); -}); - -test('authenticated architecture intent compiles only the free SQL SKU', () => { - const plan = compileArchitecture(authenticatedIntent); - - assert.ok(plan.modules.includes('azure-sql-free')); - assert.ok(plan.guarantees.includes('no-paid-sql-fallback')); - - const sqlResources = plan.resources.filter( - resource => resource.type === 'Microsoft.Sql/servers/databases', - ); - assert.ok( - sqlResources.some(resource => resource.sku === 'GP_S_Gen5_2'), - 'compiled plan must include the free Azure SQL database SKU', - ); - assert.ok( - sqlResources.every(resource => resource.sku === 'GP_S_Gen5_2'), - 'compiled plan must not include a SQL database with a different SKU', - ); -}); - -test('deployment reference defines the architecture compiler migration boundary', async () => { - const reference = await readFile('docs/reference/deployment.md', 'utf8'); - - assert.match(reference, /architecture-plan/); - assert.match(reference, /does not convert an existing database/i); - assert.match(reference, /legacy paid deployment/i); - assert.match(reference, /not deployment approval/i); -}); - -test('demo deployment reference defines the architecture compiler migration boundary', async () => { - const reference = await readFile('docs/reference/demo-deployment.md', 'utf8'); - - assert.match(reference, /architecture-plan/); - assert.match(reference, /does not convert an existing database/i); - assert.match(reference, /legacy paid deployment/i); - assert.match(reference, /not deployment approval/i); -}); diff --git a/tests/architecture-module.test.ts b/tests/architecture-module.test.ts deleted file mode 100644 index c623e1c..0000000 --- a/tests/architecture-module.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { ZodError } from 'zod'; -import { - architectureModuleSchema, - validateModuleCatalog, - type ArchitectureModule, - type ArchitectureModuleCatalog, -} from '../src/architecture-module.js'; - -function moduleFixture(overrides: Partial = {}): ArchitectureModule { - return { - id: 'core', - version: 1, - surface: 0, - provides: ['application-api'], - requiresCapabilities: [], - requiresModules: [], - incompatibleModules: [], - guarantees: ['private-data-path'], - resources: [{ type: 'Microsoft.Web/sites', sku: 'B1', purpose: 'Hosts the application API' }], - acceptanceChecks: ['api-health-check'], - growthTransitions: [], - ...overrides, - }; -} - -test('validateModuleCatalog returns a catalog sorted by module ID', () => { - const descriptors: unknown[] = [ - { ...moduleFixture({ id: 'core' }) }, - { ...moduleFixture({ id: 'api', requiresModules: ['core'] }) }, - ]; - const catalog: ArchitectureModuleCatalog = validateModuleCatalog(descriptors); - - assert.deepEqual([...catalog.keys()], ['api', 'core']); - assert.equal(catalog.get('api')?.requiresModules[0], 'core'); -}); - -test('validateModuleCatalog rejects a malformed raw descriptor', () => { - const descriptors: unknown[] = [ - moduleFixture(), - { ...moduleFixture({ id: 'api' }), version: 0 }, - ]; - - assert.throws( - () => validateModuleCatalog(descriptors), - error => { - assert.ok(error instanceof ZodError); - assert.deepEqual(error.issues[0]?.path, [1, 'version']); - return true; - }, - ); -}); - -test('architectureModuleSchema accepts module IDs matching the contract regex', () => { - for (const id of ['core-', 'core--api']) { - assert.equal( - architectureModuleSchema.safeParse(moduleFixture({ id })).success, - true, - `expected module ID ${JSON.stringify(id)} to be accepted`, - ); - } -}); - -test('architectureModuleSchema rejects module IDs outside the contract regex', () => { - for (const id of ['Core', 'a', '1core', '-core', 'core_api', 'a'.repeat(65)]) { - assert.equal( - architectureModuleSchema.safeParse(moduleFixture({ id })).success, - false, - `expected module ID ${JSON.stringify(id)} to be rejected`, - ); - } -}); - -test('architectureModuleSchema requires positive integer versions', () => { - for (const version of [0, -1, 1.5]) { - assert.equal( - architectureModuleSchema.safeParse(moduleFixture({ version })).success, - false, - `expected version ${version} to be rejected`, - ); - } -}); - -test('architectureModuleSchema requires nonnegative integer surfaces', () => { - for (const surface of [-1, 1.5]) { - assert.equal( - architectureModuleSchema.safeParse(moduleFixture({ surface })).success, - false, - `expected surface ${surface} to be rejected`, - ); - } -}); - -test('architectureModuleSchema rejects empty provides', () => { - assert.equal( - architectureModuleSchema.safeParse(moduleFixture({ provides: [] })).success, - false, - ); -}); - -test('architectureModuleSchema rejects duplicate acceptance checks', () => { - assert.equal( - architectureModuleSchema.safeParse(moduleFixture({ - acceptanceChecks: ['api-health-check', 'api-health-check'], - })).success, - false, - ); -}); - -test('architectureModuleSchema rejects duplicate name-list entries', () => { - const duplicate = ['duplicate-name', 'duplicate-name']; - const fields: Array> = [ - 'provides', - 'requiresCapabilities', - 'requiresModules', - 'incompatibleModules', - 'guarantees', - 'growthTransitions', - ]; - - for (const field of fields) { - assert.equal( - architectureModuleSchema.safeParse({ ...moduleFixture(), [field]: duplicate }).success, - false, - `expected duplicate ${field} to be rejected`, - ); - } -}); - -test('architectureModuleSchema accepts a resource without a sku', () => { - const result = architectureModuleSchema.safeParse(moduleFixture({ - resources: [{ type: 'Microsoft.Web/sites', purpose: 'Hosts the application API' }], - })); - - assert.equal(result.success, true); -}); - -test('architectureModuleSchema rejects empty resource fields', () => { - for (const resources of [ - [{ type: '', purpose: 'Hosts the application API' }], - [{ type: 'Microsoft.Web/sites', purpose: '' }], - [{ type: 'Microsoft.Web/sites', sku: '', purpose: 'Hosts the application API' }], - ]) { - assert.equal( - architectureModuleSchema.safeParse(moduleFixture({ resources })).success, - false, - ); - } -}); - -test('architectureModuleSchema rejects unknown resource fields', () => { - assert.equal( - architectureModuleSchema.safeParse({ - ...moduleFixture(), - resources: [{ - type: 'Microsoft.Web/sites', - purpose: 'Hosts the application API', - region: 'westus', - }], - }).success, - false, - ); -}); - -test('validateModuleCatalog rejects duplicate module IDs', () => { - assert.throws( - () => validateModuleCatalog([moduleFixture(), moduleFixture()]), - /duplicate module ID.*core/i, - ); -}); - -test('validateModuleCatalog rejects a missing required module', () => { - assert.throws( - () => validateModuleCatalog([moduleFixture({ requiresModules: ['database'] })]), - /unknown required module.*database/i, - ); -}); - -test('validateModuleCatalog reports the path of required-module dependency cycles', () => { - assert.throws( - () => validateModuleCatalog([ - moduleFixture({ id: 'a-module', requiresModules: ['b-module'] }), - moduleFixture({ id: 'b-module', requiresModules: ['a-module'] }), - ]), - /a-module\s*->\s*b-module\s*->\s*a-module/, - ); -}); - -test('validateModuleCatalog rejects a module that requires and rejects the same module', () => { - assert.throws( - () => validateModuleCatalog([ - moduleFixture({ id: 'api', requiresModules: ['core'], incompatibleModules: ['core'] }), - moduleFixture(), - ]), - /api.*both requires and rejects.*core/i, - ); -}); - -test('architectureModuleSchema rejects empty acceptance checks', () => { - assert.equal( - architectureModuleSchema.safeParse(moduleFixture({ acceptanceChecks: [] })).success, - false, - ); -}); - -test('architectureModuleSchema is strict and enforces slug names', () => { - assert.equal( - architectureModuleSchema.safeParse({ ...moduleFixture(), unexpected: true }).success, - false, - ); - assert.equal( - architectureModuleSchema.safeParse(moduleFixture({ growthTransitions: ['Invalid transition'] })).success, - false, - ); -}); - -test('validateModuleCatalog rejects unknown incompatible modules and growth transitions', () => { - assert.throws( - () => validateModuleCatalog([moduleFixture({ incompatibleModules: ['missing-module'] })]), - /unknown incompatible module.*missing-module/i, - ); - assert.throws( - () => validateModuleCatalog([moduleFixture({ growthTransitions: ['missing-module'] })]), - /unknown growth transition.*missing-module/i, - ); -}); diff --git a/tests/artifacts.test.ts b/tests/artifacts.test.ts index ebc25d3..3b928f5 100644 --- a/tests/artifacts.test.ts +++ b/tests/artifacts.test.ts @@ -5,11 +5,12 @@ import { resolve } from 'node:path'; import { deploymentSchema } from '../src/config.js'; import { buildArtifacts, imageDigest, imageFields } from '../src/artifacts.js'; import type { Run } from '../src/process.js'; +import { roleBasedSchemaFingerprint } from '../src/role-based-profile.js'; const config = deploymentSchema.parse(JSON.parse(await readFile('sql-apps.example.json', 'utf8'))); const digest = `sha256:${'a'.repeat(64)}`; -test('artifact builds use ACR for all services and return immutable validated configuration', async () => { +test('artifact builds use ACR for the gateway and DAB images and return immutable validated configuration', async () => { const calls: string[][] = []; const run: Run = async (command, args) => { calls.push([command, ...args]); @@ -17,18 +18,26 @@ test('artifact builds use ACR for all services and return immutable validated co }; const result = await buildArtifacts(config, run); const builds = calls.filter(call => call.includes('build')); - assert.equal(builds.length, 3); + assert.equal(builds.length, 2); assert.deepEqual(builds.map(call => call[call.indexOf('--file') + 1]), [ - resolve('Dockerfile'), resolve('dab', 'Dockerfile'), resolve('functions', 'Dockerfile'), + resolve('Dockerfile'), resolve('dab', 'Dockerfile'), ]); - assert.deepEqual(builds.map(call => call.at(-1)), [resolve('.'), resolve('.'), resolve('.')]); + assert.deepEqual(builds.map(call => call.at(-1)), [resolve('.'), resolve('.')]); for (const field of imageFields) { assert.equal(result[field], `${config[field].split(':')[0]}@${digest}`); } - assert.equal(config.gatewayImage.endsWith(':0.1.2'), true); + assert.equal(result.imageSource, await roleBasedSchemaFingerprint(process.cwd())); + assert.equal(config.gatewayImage.endsWith(':0.2.0'), true); assert.equal(calls.every(call => call.includes(config.subscriptionId)), true); }); +test('Docker build context excludes workspace-only state', async () => { + const ignored = new Set((await readFile('.dockerignore', 'utf8')).split(/\r?\n/).filter(Boolean)); + for (const path of ['.git', '.worktrees', '.vscode', '.azure', '.sql-apps', 'sql-apps.json']) { + assert.equal(ignored.has(path), true, `${path} must not enter remote ACR build contexts`); + } +}); + test('artifact builds fail explicitly and stop before later builds', async () => { let builds = 0; const run: Run = async (_command, args) => { @@ -39,10 +48,9 @@ test('artifact builds fail explicitly and stop before later builds', async () => assert.equal(builds, 2); }); -test('builds reject digest outputs and colliding image references before side effects', async () => { +test('builds reject colliding image references before side effects', async () => { let calls = 0; const run: Run = async () => { calls++; return ''; }; - await assert.rejects(buildArtifacts({ ...config, gatewayImage: `${config.registryServer}/gateway@${digest}` }, run), /version tags/); await assert.rejects(buildArtifacts({ ...config, dabImage: config.gatewayImage }, run), /distinct/); assert.equal(calls, 0); }); diff --git a/tests/auth-redirect.test.ts b/tests/auth-redirect.test.ts index 8613070..4eac104 100644 --- a/tests/auth-redirect.test.ts +++ b/tests/auth-redirect.test.ts @@ -9,16 +9,13 @@ test('Microsoft sign-in callback is public and isolated from the application', a const app = await createGateway({ tenantId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', apiClientId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + requiredRole: 'AppUser', + readinessPath: '/api/AppReady', dabUrl: 'https://data.internal', - blobAccountUrl: 'https://unconfigured.invalid', - blobContainer: 'files', - functionsUrl: 'https://unconfigured.invalid', publicDirectory: 'public', }, { verifyUser: unexpected, - files: { put: unexpected, get: unexpected, delete: unexpected, list: unexpected }, fetch: unexpected, - functionToken: unexpected, }); t.after(() => app.close()); const callback = await app.inject(authRedirectPath); @@ -31,7 +28,7 @@ test('Microsoft sign-in callback is public and isolated from the application', a }); test('Microsoft sign-in callback uses the MSAL redirect bridge without product startup', async () => { - const source = await readFile('src\\web\\auth-redirect.ts', 'utf8'); + const source = await readFile('src/web/auth-redirect.ts', 'utf8'); assert.match(source, /@azure\/msal-browser\/redirect-bridge/); assert.doesNotMatch(source, /fetch\(|configureSession|PublicClientApplication/); }); diff --git a/tests/auth.test.ts b/tests/auth.test.ts index ecdb2a0..d799cde 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -2,7 +2,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { generateKeyPair, SignJWT } from 'jose'; import { bearerToken, entraVerifier } from '../src/auth.js'; -import { serviceVerifier } from '../functions/src/authorization.js'; const tenant = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const audience = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; @@ -33,10 +32,3 @@ test('Bearer parsing rejects whitespace and missing token', () => { assert.equal(bearerToken('Bearer abc def'), undefined); assert.equal(bearerToken(undefined), undefined); }); -test('function requires gateway application identity and invocation role', async () => { - const verifyService = serviceVerifier(tenant, audience, oid, async () => keys.publicKey); - await verifyService(await token({ scp: undefined, roles: ['Function.Invoke'] })); - await assert.rejects(verifyService(await token({ roles: ['Function.Invoke'] }))); - await assert.rejects(verifyService(await token({ scp: undefined, roles: [] }))); - await assert.rejects(verifyService(await token({ scp: undefined, roles: ['Function.Invoke'], oid: audience }))); -}); diff --git a/tests/azure-init.test.ts b/tests/azure-init.test.ts index f5b77f2..15174b0 100644 --- a/tests/azure-init.test.ts +++ b/tests/azure-init.test.ts @@ -10,6 +10,7 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import type { DeploymentConfig } from '../src/config.js'; import type { DeploymentState } from '../src/deployment.js'; +import { roleBasedSchemaFingerprint } from '../src/role-based-profile.js'; const execute = promisify(execFile); @@ -36,6 +37,16 @@ async function home(t: { after(fn: () => Promise): void }, roleBased = tru return root; } +async function project(t: { after(fn: () => Promise): void }): Promise<{ root: string; sql: string }> { + const root = await home(t); + await mkdir(join(root, 'dab')); + await mkdir(join(root, 'sql')); + await writeFile(join(root, 'dab', 'dab-config.json'), '{}'); + const sql = join(root, 'sql', 'Ready.sql'); + await writeFile(sql, 'CREATE PROCEDURE dbo.Ready AS SELECT 1;'); + return { root, sql }; +} + test('deployment names stay bounded without collisions after normalization', () => { const first = deploymentName('customer-portal-production'); const second = deploymentName('customer-portal-staging'); @@ -48,7 +59,8 @@ test('deployment names stay bounded without collisions after normalization', () test('azure init derives tenant, subscription, operator and role settings and asks only for placement', async t => { const root = await home(t); const { run, calls } = signedIn(); - const config = await initDeploymentConfig({ profile: 'role-based-data', location: 'eastus', resourceGroup: 'rg-expense', registryServer: 'expense.azurecr.io' }, run, root); + const config = await initDeploymentConfig({ location: 'westus2', resourceGroup: 'rg-expense', registryServer: 'expense.azurecr.io' }, run, root); + assert.equal(config.profile, 'role-based-data', 'POC initialization defaults to the lowest-cost cloud profile'); assert.deepEqual(deploymentSchema.parse(config), config); assert.equal(config.tenantId, tenant); assert.equal(config.subscriptionId, subscription); @@ -60,23 +72,18 @@ test('azure init derives tenant, subscription, operator and role settings and as assert.equal(config.environment, 'dev'); assert.equal(config.apiClientId, unsetClientId, 'identity fills the client id; init never invents one'); assert.match(config.gatewayImage, /^expense\.azurecr\.io\/expense-tracker-gateway:/); - assert.equal(config.functionsImage, ''); + assert.equal('functionsImage' in config, false); assert.ok(calls.every(call => call[1] === 'account' || call.join(' ').includes('signed-in-user show')), 'init must only read Azure'); }); -test('azure init builds a foundation config with a functions image and rejects service principals', async t => { - const root = await home(t, false); - const config = await initDeploymentConfig({ profile: 'foundation', location: 'westus2', resourceGroup: 'rg', registryServer: 'r1.azurecr.io', environment: 'test', name: 'demo-app' }, signedIn().run, root); - assert.equal(config.profile, 'foundation'); - assert.equal(config.requiredRole, undefined); - assert.match(config.functionsImage, /^r1\.azurecr\.io\/demo-app-functions:/); - await assert.rejects(initDeploymentConfig({ profile: 'foundation', location: 'westus2', resourceGroup: 'rg', registryServer: 'r1.azurecr.io' }, signedIn('servicePrincipal').run, root), /signed-in user/); - await assert.rejects(initDeploymentConfig({ profile: 'role-based-data', location: 'westus2', resourceGroup: 'rg', registryServer: 'r1.azurecr.io' }, signedIn().run, root), /role-based-data\.json/); +test('azure init rejects service principals and projects without role-based-data.json', async t => { + await assert.rejects(initDeploymentConfig({ location: 'westus2', resourceGroup: 'rg', registryServer: 'r1.azurecr.io' }, signedIn('servicePrincipal').run, await home(t)), /signed-in user/); + await assert.rejects(initDeploymentConfig({ location: 'westus2', resourceGroup: 'rg', registryServer: 'r1.azurecr.io' }, signedIn().run, await home(t, false)), /role-based-data\.json/); }); test('deploy-all review is read-only and a stale approval id runs nothing; init never overwrites', async t => { - const root = await home(t, false); - const config = await initDeploymentConfig({ profile: 'foundation', location: 'eastus', resourceGroup: 'rg', registryServer: 'r1.azurecr.io' }, signedIn().run, root); + const root = await home(t); + const config = await initDeploymentConfig({ location: 'eastus', resourceGroup: 'rg', registryServer: 'r1.azurecr.io' }, signedIn().run, root); const path = join(root, 'azure.json'); await writeFile(path, JSON.stringify(config)); const cli = resolve('dist/src/cli.js'); @@ -84,23 +91,29 @@ test('deploy-all review is read-only and a stale approval id runs nothing; init assert.match(shown, /Pending paid Azure steps .*: identity -> artifacts -> provision -> deploy -> status/); assert.match(shown, /--approve [a-f0-9]{12}/); await assert.rejects(run(process.execPath, [cli, 'deploy-all', path, '--approve', '000000000000']), /changed since review/); - await assert.rejects(run(process.execPath, [cli, 'init', path, '--profile', 'foundation']), /already exists/); - await assert.rejects(run(process.execPath, [cli, 'plan', path]), /run identity/); + await assert.rejects(run(process.execPath, [cli, 'init', path, '--location', 'eastus', '--resource-group', 'rg', '--registry', 'r1.azurecr.io']), /already exists/); + const repositoryRole = join(root, 'repository-role.json'); + await writeFile(repositoryRole, JSON.stringify({ ...config, requiredRole: 'AppUser', readinessPath: '/api/AppReady' })); + await assert.rejects(run(process.execPath, [cli, 'plan', repositoryRole]), /run identity/); }); test('deploy-all lists only pending paid steps and binds approval to the config and remaining work', async t => { const root = await home(t); - const config = await initDeploymentConfig({ profile: 'role-based-data', location: 'eastus', resourceGroup: 'rg-expense', registryServer: 'expense.azurecr.io' }, signedIn().run, root); - const fresh = planDeployAll(config, undefined); + const config = await initDeploymentConfig({ location: 'westus2', resourceGroup: 'rg-expense', registryServer: 'expense.azurecr.io' }, signedIn().run, root); + const source = 'c'.repeat(64); + const fresh = planDeployAll(config, undefined, source); assert.deepEqual(fresh.pending, ['identity', 'artifacts', 'provision', 'deploy', 'status']); assert.match(fresh.approval, /^[a-f0-9]{12}$/); const digest = `@sha256:${'a'.repeat(64)}`; const pinned = { ...config, apiClientId: '33333333-3333-4333-8333-333333333333', - gatewayImage: `expense.azurecr.io/expense-tracker-gateway${digest}`, dabImage: `expense.azurecr.io/expense-tracker-data${digest}` }; - const resumed = planDeployAll(pinned, 'schema'); + gatewayImage: `expense.azurecr.io/expense-tracker-gateway${digest}`, dabImage: `expense.azurecr.io/expense-tracker-data${digest}`, imageSource: source }; + const resumed = planDeployAll(pinned, 'schema', source); assert.deepEqual(resumed.pending, ['deploy', 'status']); assert.notEqual(resumed.approval, fresh.approval); - assert.deepEqual(planDeployAll(pinned, 'ready').pending, ['status']); + assert.deepEqual(planDeployAll(pinned, 'ready', source).pending, ['status']); + assert.deepEqual(planDeployAll(pinned, 'ready', 'd'.repeat(64)).pending, ['artifacts', 'status'], + 'images built from older SQL/DAB source make artifacts pending again'); + assert.ok(fresh.excluded.some(item => /rbac-deploy/.test(item)), 'privileged Azure RBAC stays a separate administrator decision'); assert.ok(fresh.excluded.some(item => /role-based-assign/.test(item)), 'role assignment names a person and stays a separate decision'); }); @@ -117,45 +130,39 @@ async function saveDeployment(root: string, config: DeploymentConfig, stage: Dep outputs: { sqlServer: 'fixture.database.windows.net', databaseName: 'app', dabPrincipalId: '55555555-5555-4555-8555-555555555555', gatewayPrincipalId: '66666666-6666-4666-8666-666666666666', - gatewayName: 'fixture-gateway', gatewayUrl: 'https://fixture.invalid', functionsName: 'fixture-functions', - networkId: 'fixture-network', storageAccount: 'fixturestorage', vaultName: 'fixture-vault', + gatewayName: 'fixture-gateway', gatewayUrl: 'https://fixture.invalid', networkId: 'fixture-network', sqlBackupRetentionDays: 7, sqlBackupRedundancy: 'Geo', sqlAuditWorkspaceName: 'fixture-logs' } }); `], { cwd: root }); } -test('deploy-all rejects changed image digests instead of skipping a saved deployment', async t => { - const root = await home(t, false); - const initial = await initDeploymentConfig({ profile: 'foundation', location: 'eastus', resourceGroup: 'rg', registryServer: 'r1.azurecr.io' }, signedIn().run, root); +test('deploy-all makes deploy pending again when the configuration changes after a saved deployment', async t => { + const { root } = await project(t); + const initial = await initDeploymentConfig({ location: 'eastus', resourceGroup: 'rg', registryServer: 'r1.azurecr.io' }, signedIn().run, root); const digest = `@sha256:${'a'.repeat(64)}`; - const config = { ...initial, apiClientId: operator, - gatewayImage: `r1.azurecr.io/gateway${digest}`, dabImage: `r1.azurecr.io/data${digest}`, functionsImage: `r1.azurecr.io/functions${digest}` }; + const config = { ...initial, apiClientId: operator, imageSource: await roleBasedSchemaFingerprint(root), + gatewayImage: `r1.azurecr.io/gateway${digest}`, dabImage: `r1.azurecr.io/data${digest}` }; const cli = resolve('dist/src/cli.js'); const path = join(root, 'azure.json'); for (const stage of ['infrastructure', 'ready'] as const) { await saveDeployment(root, config, stage); await writeFile(path, JSON.stringify(config)); const unchanged = await execute(process.execPath, [cli, 'deploy-all', path], { cwd: root }); - assert.match(unchanged.stdout, stage === 'ready' ? /: status/ : /: deploy -> status/); + assert.match(unchanged.stdout, stage === 'ready' ? /: status\r?\n/ : /: deploy -> status/); await writeFile(path, JSON.stringify({ ...config, gatewayImage: `r1.azurecr.io/gateway@sha256:${'b'.repeat(64)}` })); - await assert.rejects(execute(process.execPath, [cli, 'deploy-all', path], { cwd: root }), - /configuration or SQL\/DAB source changed.*review.*saved deployment/i); + const changed = await execute(process.execPath, [cli, 'deploy-all', path], { cwd: root }); + assert.match(changed.stdout, /: deploy -> status/); } }); -test('deploy-all rejects changed role-based SQL source before reusing a ready stage', async t => { - const root = await home(t); - await mkdir(join(root, 'dab')); - await mkdir(join(root, 'sql')); - await writeFile(join(root, 'dab', 'dab-config.json'), '{}'); - const sql = join(root, 'sql', 'Ready.sql'); - await writeFile(sql, 'CREATE PROCEDURE dbo.Ready AS SELECT 1;'); - const initial = await initDeploymentConfig({ profile: 'role-based-data', location: 'eastus', resourceGroup: 'rg', registryServer: 'r1.azurecr.io' }, signedIn().run, root); - const config = { ...initial, apiClientId: operator, +test('deploy-all rebuilds artifacts and redeploys after a SQL source change to a ready deployment', async t => { + const { root, sql } = await project(t); + const initial = await initDeploymentConfig({ location: 'eastus', resourceGroup: 'rg', registryServer: 'r1.azurecr.io' }, signedIn().run, root); + const config = { ...initial, apiClientId: operator, imageSource: await roleBasedSchemaFingerprint(root), gatewayImage: `r1.azurecr.io/gateway@sha256:${'a'.repeat(64)}`, dabImage: `r1.azurecr.io/data@sha256:${'a'.repeat(64)}` }; await saveDeployment(root, config, 'ready'); const path = join(root, 'azure.json'); await writeFile(path, JSON.stringify(config)); await writeFile(sql, 'CREATE PROCEDURE dbo.Ready AS SELECT 2;'); - await assert.rejects(execute(process.execPath, [resolve('dist/src/cli.js'), 'deploy-all', path], { cwd: root }), - /configuration or SQL\/DAB source changed.*review.*saved deployment/i); + const { stdout } = await execute(process.execPath, [resolve('dist/src/cli.js'), 'deploy-all', path], { cwd: root }); + assert.match(stdout, /: artifacts -> deploy -> status/); }); diff --git a/tests/azure-preflight.test.ts b/tests/azure-preflight.test.ts deleted file mode 100644 index fd5b838..0000000 --- a/tests/azure-preflight.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { preflightDemo, DemoPreflightError, demoTargetSchema } from '../src/azure-preflight.js'; -import type { Run } from '../src/process.js'; -import { run } from '../src/process.js'; - -const target = { - profile: 'public-demo' as const, - securityPosture: 'starter' as const, - tenantId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - subscriptionId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', - resourceGroup: 'sql-apps-demo', location: 'eastus', -}; -const otherTenant = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; -const secret = 'synthetic-sensitive-token'; -function harness(options: { - cacheFailure?: boolean; tokenFailure?: string; tenant?: string; expired?: boolean; - armTenant?: string; forbidden?: string; missingGroup?: boolean; provider?: string; wrongRegion?: boolean; - permissions?: readonly { actions: readonly string[]; notActions: readonly string[] }[]; -} = {}) { - const calls: string[][] = []; - const requests: string[] = []; - const runner: Run = async (command, args) => { - calls.push([command, ...args]); - if (args[0] === 'account' && args[1] === 'list') { - if (options.cacheFailure) throw new Error(`Default-tenant Graph denied; ${secret}`); - return '[]'; - } - if (args[0] === 'account' && args[1] === 'get-access-token') { - assert.ok(args.includes('--tenant')); - assert.equal(args[args.indexOf('--tenant') + 1], target.tenantId); - assert.ok(args.includes('https://management.azure.com/')); - if (options.tokenFailure) throw new Error(`${options.tokenFailure}; ${secret}`); - return JSON.stringify({ accessToken: secret, tenant: options.tenant ?? target.tenantId, - expires_on: String(Math.floor(Date.now() / 1000) + (options.expired ? -30 : 3600)) }); - } - throw new Error(`Unexpected CLI operation: ${args.join(' ')}`); - }; - const fetcher: typeof fetch = async (input, init) => { - const url = new URL(String(input)); - requests.push(url.href); - assert.equal(url.origin, 'https://management.azure.com'); - assert.ok(url.pathname.startsWith(`/subscriptions/${target.subscriptionId}`)); - assert.equal(new Headers(init?.headers).get('authorization'), `Bearer ${secret}`); - assert.equal(init?.redirect, 'error'); - assert.ok(init?.signal); - if (options.forbidden) return Response.json({ error: { code: options.forbidden, message: secret } }, { status: 403 }); - if (url.pathname.endsWith('/permissions')) return Response.json({ value: options.permissions ?? [{ actions: ['*'], notActions: [] }] }); - if (url.pathname.endsWith(`/resourcegroups/${target.resourceGroup}`)) { - return options.missingGroup ? Response.json({}, { status: 404 }) : - Response.json({ id: `/subscriptions/${target.subscriptionId}/resourceGroups/${target.resourceGroup}`, location: target.location }); - } - const provider = /\/providers\/(Microsoft\.[^/]+)$/.exec(url.pathname)?.[1]; - if (provider) { - const types: Record = { - 'Microsoft.App': ['managedEnvironments', 'containerApps'], - 'Microsoft.Sql': ['servers'], - 'Microsoft.Network': ['virtualNetworks'], - 'Microsoft.ContainerRegistry': ['registries'], - 'Microsoft.ManagedIdentity': ['userAssignedIdentities'], - 'Microsoft.Authorization': ['roleAssignments'], - 'Microsoft.Web': ['staticSites', 'staticSites/linkedBackends'], - }; - return Response.json({ namespace: provider, registrationState: options.provider === provider ? 'NotRegistered' : 'Registered', - resourceTypes: types[provider]!.map(resourceType => ({ resourceType, locations: [options.wrongRegion ? 'West Europe' : 'East US'] })) }); - } - if (url.pathname.endsWith('/locations')) return Response.json({ value: [{ name: target.location, displayName: 'East US' }] }); - return Response.json({ subscriptionId: target.subscriptionId, tenantId: options.armTenant ?? target.tenantId, state: 'Enabled' }); - }; - return { runner, fetcher, calls, requests }; -} - -test('public demo preflight accepts actual tenant-scoped ARM access despite an empty subscription cache', async () => { - const fake = harness(); - const result = await preflightDemo(target, fake.runner, fake.fetcher); - assert.equal(result.cache.status, 'not-found'); - assert.equal(result.arm.status, 'verified'); - assert.equal(result.authorization.status, 'eligible-for-what-if'); - assert.equal(result.policy.status, 'not-yet-evaluated'); - assert.equal(result.costs.status, 'not-evaluated'); - assert.match(result.costs.action, /demo-cost.*before what-if/); - assert.ok(!JSON.stringify(result).includes(secret)); - assert.ok(fake.calls.every(call => !call.includes('ad') && !call.includes('set') && !call.includes('login'))); -}); - -test('starter preflight does not require role-assignment writes', async () => { - const fake = harness({ permissions: [{ actions: ['*'], notActions: ['Microsoft.Authorization/roleAssignments/write'] }] }); - const result = await preflightDemo(target, fake.runner, fake.fetcher); - assert.equal(result.securityPosture, 'starter'); - assert.equal(result.authorization.status, 'eligible-for-what-if'); - assert.ok(!fake.requests.some(request => request.includes('/providers/Microsoft.Authorization?'))); -}); - -test('starter preflight requires ACR credential listing', async () => { - const fake = harness({ - permissions: [{ actions: ['*'], notActions: ['Microsoft.ContainerRegistry/registries/listCredentials/action'] }], - }); - await assert.rejects(preflightDemo(target, fake.runner, fake.fetcher), error => - error instanceof DemoPreflightError && error.code === 'WRITE_AUTHORIZATION_MISSING' && - /listCredentials/.test(error.message)); -}); - -test('hardened preflight requires role-assignment writes', async () => { - const fake = harness({ permissions: [{ actions: ['*'], notActions: ['Microsoft.Authorization/roleAssignments/write'] }] }); - await assert.rejects(preflightDemo( - { ...target, securityPosture: 'hardened' }, - fake.runner, - fake.fetcher, - ), error => error instanceof DemoPreflightError && error.code === 'WRITE_AUTHORIZATION_MISSING' && - /roleAssignments/.test(error.message)); -}); - -test('default-tenant discovery/Graph errors are separate evidence, not a revoked-login conclusion', async () => { - const fake = harness({ cacheFailure: true, missingGroup: true }); - const result = await preflightDemo(target, fake.runner, fake.fetcher); - assert.equal(result.cache.status, 'unavailable'); - assert.equal(result.resourceGroup.status, 'requires-approved-creation'); - assert.equal(result.arm.status, 'verified'); - assert.ok(!JSON.stringify(result).includes(secret)); -}); - -test('tenant mismatch and expired tokens stop before confusing cache evidence with authoritative access', async () => { - for (const [options, code] of [ - [{ tenant: otherTenant }, 'TOKEN_TENANT_MISMATCH'], - [{ armTenant: otherTenant }, 'SUBSCRIPTION_TENANT_MISMATCH'], - [{ expired: true }, 'TOKEN_EXPIRED'], - [{ tokenFailure: 'AADSTS700082 expired refresh token' }, 'TENANT_TOKEN_REQUIRED'], - ] as const) { - const fake = harness(options); - await assert.rejects(preflightDemo(target, fake.runner, fake.fetcher), error => - error instanceof DemoPreflightError && error.code === code && !JSON.stringify(error).includes(secret) && - !error.message.includes(secret)); - if ('expired' in options || 'tenant' in options || 'tokenFailure' in options) assert.equal(fake.requests.length, 0); - } -}); - -test('policy, ARM denial, provider availability, region and write authorization have distinct safe failures', async () => { - for (const [options, code] of [ - [{ forbidden: 'RequestDisallowedByPolicy' }, 'AZURE_POLICY_DENIED'], - [{ forbidden: 'AuthorizationFailed' }, 'ARM_FORBIDDEN'], - [{ provider: 'Microsoft.Sql' }, 'PROVIDER_REGISTRATION_REQUIRED'], - [{ wrongRegion: true }, 'REGION_UNSUPPORTED'], - [{ permissions: [{ actions: ['*/read'], notActions: [] }] }, 'WRITE_AUTHORIZATION_MISSING'], - [{ permissions: [{ actions: ['*'], notActions: ['Microsoft.Sql/servers/firewallRules/delete'] }] }, 'WRITE_AUTHORIZATION_MISSING'], - [{ permissions: [{ actions: ['*'], notActions: ['Microsoft.ManagedIdentity/userAssignedIdentities/assign/action'] }] }, 'WRITE_AUTHORIZATION_MISSING'], - [{ permissions: [{ actions: ['*'], notActions: ['Microsoft.Network/virtualNetworks/subnets/joinViaServiceEndpoint/action'] }] }, 'WRITE_AUTHORIZATION_MISSING'], - ] as const) { - const fake = harness(options); - await assert.rejects(preflightDemo(target, fake.runner, fake.fetcher), error => - error instanceof DemoPreflightError && error.code === code && !error.message.includes(secret)); - assert.ok(fake.calls.every(call => call[1] === 'account')); - } -}); - -test('profile and unknown target settings are rejected before Azure operations', async () => { - const fake = harness(); - for (const value of [ - { ...target, profile: 'authenticated-application' }, { ...target, location: 'eastus;injected' }, - { ...target, graph: true }, { ...target, subscriptionId: 'not-a-subscription' }, - { ...target, securityPosture: 'automatic' }, - ]) { - assert.equal(demoTargetSchema.safeParse(value).success, false); - await assert.rejects(preflightDemo(value, fake.runner, fake.fetcher)); - } - assert.equal(fake.calls.length, 0); -}); - -test('ARM transient failures retry narrowly and stop at a bounded count', async () => { - const fake = harness(); - let attempts = 0; - const unstable: typeof fetch = async (input, init) => { - if (String(input).endsWith('api-version=2022-12-01')) { - attempts++; - return Response.json({ error: { code: 'ServiceUnavailable' } }, { status: 503 }); - } - return fake.fetcher(input, init); - }; - await assert.rejects(preflightDemo(target, fake.runner, unstable), error => - error instanceof DemoPreflightError && error.code === 'ARM_TRANSIENT_FAILURE'); - assert.equal(attempts, 3); -}); - -test('CLI exposes separate read-only demo diagnostics without requiring full-foundation configuration', async () => { - const output = await run(process.execPath, ['dist/src/cli.js', 'help']); - assert.match(output, /demo-preflight/); - assert.doesNotMatch(output, /Run npm run build/, 'help must not bypass the guarded consent-run build path'); - await assert.rejects(run(process.execPath, ['dist/src/cli.js', 'demo-preflight']), /explicit.*target configuration/i); -}); - -test('CLI reports configuration schema problems as readable paths, not raw JSON issue dumps', async t => { - const { mkdtemp, readFile, rm, writeFile } = await import('node:fs/promises'); - const { join } = await import('node:path'); - const { tmpdir } = await import('node:os'); - const directory = await mkdtemp(join(tmpdir(), 'sql-apps-zod-')); - t.after(() => rm(directory, { recursive: true, force: true })); - const path = join(directory, 'bad.json'); - await writeFile(path, JSON.stringify({ ...JSON.parse(await readFile('azure-role-based.example.json', 'utf8')), bogusKey: 1 })); - await assert.rejects(run(process.execPath, ['dist/src/cli.js', 'validate', path]), (error: Error) => - /bogusKey/.test(error.message) && !error.message.includes('"code"')); -}); - -test('offline validate compares the Azure role-based config with role-based-data.json and requires declared procedures', async t => { - const { mkdtemp, mkdir, readFile, rm, writeFile } = await import('node:fs/promises'); - const { join, resolve } = await import('node:path'); - const { tmpdir } = await import('node:os'); - const { execFile } = await import('node:child_process'); - const cli = resolve('dist/src/cli.js'); - const example = JSON.parse(await readFile('azure-role-based.example.json', 'utf8')); - const dab = JSON.parse(await readFile('dab/dab-config.json', 'utf8')); - dab.entities = { ShiftsReady: { source: { object: 'dbo.ShiftsReady', type: 'stored-procedure' }, - rest: { enabled: true, methods: ['get'] }, graphql: false, permissions: [{ role: 'Volunteer', actions: ['execute'] }] } }; - const directory = await mkdtemp(join(tmpdir(), 'sql-apps-rb-validate-')); - t.after(() => rm(directory, { recursive: true, force: true })); - await mkdir(join(directory, 'dab')); - await mkdir(join(directory, 'sql')); - await writeFile(join(directory, 'dab', 'dab-config.json'), JSON.stringify(dab)); - await writeFile(join(directory, 'role-based-data.json'), - JSON.stringify({ profile: 'role-based-data', requiredRole: 'Volunteer', readinessPath: '/api/ShiftsReady' })); - const validate = (config: unknown) => writeFile(join(directory, 'azure.json'), JSON.stringify(config)).then(() => - new Promise<{ code: number; output: string }>(done => execFile(process.execPath, [cli, 'validate', 'azure.json'], { cwd: directory }, - (error, stdout, stderr) => done({ code: error ? Number(error.code) : 0, output: stdout + stderr })))); - - const stale = await validate(example); - assert.notEqual(stale.code, 0); - assert.match(stale.output, /requiredRole.*AppUser.*Volunteer.*role-based-data\.json/s); - - const aligned = { ...example, requiredRole: 'Volunteer', readinessPath: '/api/ShiftsReady' }; - const missing = await validate(aligned); - assert.notEqual(missing.code, 0, 'validate must not pass before artifacts/provision when a DAB procedure is absent from sql/'); - assert.match(missing.output, /dbo\.ShiftsReady/); - assert.match(missing.output, /sql\//); - - await writeFile(join(directory, 'sql', 'ShiftsReady.sql'), 'CREATE PROCEDURE dbo.ShiftsReady AS SELECT 1;'); - const valid = await validate(aligned); - assert.equal(valid.code, 0, valid.output); - assert.match(valid.output, /Configuration valid/); -}); - -test('permission pagination follows same-scope pages before deciding that write access is missing', async () => { - const fake = harness(); - let pages = 0; - const paged: typeof fetch = async (input, init) => { - const url = new URL(String(input)); - if (url.pathname.endsWith('/permissions')) { - pages++; - if (!url.searchParams.has('$skiptoken')) return Response.json({ - value: [{ actions: ['*/read'], notActions: [] }], nextLink: `${url.href}&$skiptoken=next`, - }); - } - return fake.fetcher(input, init); - }; - assert.equal((await preflightDemo(target, fake.runner, paged)).authorization.status, 'eligible-for-what-if'); - assert.equal(pages, 2); -}); - -test('ARM pagination rejects foreign endpoints and cycles without forwarding the token', async () => { - for (const kind of ['foreign-host', 'foreign-subscription', 'foreign-version', 'userinfo', 'fragment', 'http', 'cycle']) { - const fake = harness(); - const paged: typeof fetch = async (input, init) => { - const url = new URL(String(input)); - assert.equal(url.origin, 'https://management.azure.com'); - assert.ok(url.pathname.startsWith(`/subscriptions/${target.subscriptionId}`)); - if (url.pathname.endsWith('/permissions')) { - const next = new URL(url); - if (kind === 'foreign-host') next.hostname = 'unrelated.invalid'; - if (kind === 'foreign-subscription') next.pathname = next.pathname.replace(target.subscriptionId, otherTenant); - if (kind === 'foreign-version') next.searchParams.set('api-version', '2020-01-01'); - if (kind === 'userinfo') next.username = 'untrusted'; - if (kind === 'fragment') next.hash = 'untrusted'; - if (kind === 'http') next.protocol = 'http:'; - return Response.json({ value: [{ actions: ['*'], notActions: [] }], nextLink: next.href }); - } - return fake.fetcher(input, init); - }; - await assert.rejects(preflightDemo(target, fake.runner, paged), error => - error instanceof DemoPreflightError && error.code === 'ARM_INVALID_RESPONSE'); - } -}); - -test('ARM pagination stops at ten pages even when every next link is unique', async () => { - const fake = harness(); - let pages = 0; - const paged: typeof fetch = async (input, init) => { - const url = new URL(String(input)); - if (url.pathname.endsWith('/permissions')) { - pages++; - url.searchParams.set('$skiptoken', String(pages)); - return Response.json({ value: [{ actions: ['*'], notActions: [] }], nextLink: url.href }); - } - return fake.fetcher(input, init); - }; - await assert.rejects(preflightDemo(target, fake.runner, paged), error => - error instanceof DemoPreflightError && error.code === 'ARM_INVALID_RESPONSE'); - assert.equal(pages, 10); -}); - -test('transport retries recover transient failures but do not retry TLS failures', async () => { - for (const transient of [true, false]) { - const fake = harness(); - let attempts = 0; - const unstable: typeof fetch = async (input, init) => { - attempts++; - if (attempts === 1) throw new TypeError(secret, { - cause: Object.assign(new Error(secret), { code: transient ? 'ECONNRESET' : 'CERT_HAS_EXPIRED' }), - }); - return fake.fetcher(input, init); - }; - if (transient) assert.equal((await preflightDemo(target, fake.runner, unstable)).arm.status, 'verified'); - else { - await assert.rejects(preflightDemo(target, fake.runner, unstable), error => - error instanceof DemoPreflightError && error.code === 'ARM_TRANSPORT_FAILED' && !error.message.includes(secret)); - assert.equal(attempts, 1); - } - } -}); - -test('malformed ARM data and legacy local-time token expiration fail without exposing response contents', async () => { - const fake = harness(); - const malformed: typeof fetch = async () => Response.json({ secret }); - await assert.rejects(preflightDemo(target, fake.runner, malformed), error => - error instanceof DemoPreflightError && error.code === 'ARM_INVALID_RESPONSE' && !error.message.includes(secret)); - const legacy: Run = async (command, args) => args[1] === 'get-access-token' ? - JSON.stringify({ accessToken: secret, tenant: target.tenantId, expiresOn: '2099-01-01 00:00:00' }) : - fake.runner(command, args); - await assert.rejects(preflightDemo(target, legacy, fake.fetcher), error => - error instanceof DemoPreflightError && error.code === 'TOKEN_FORMAT_INVALID' && !error.message.includes(secret)); - assert.equal(fake.requests.length, 0); -}); diff --git a/tests/browser-auth.test.ts b/tests/browser-auth.test.ts index 9cfb0d5..f4fd5e8 100644 --- a/tests/browser-auth.test.ts +++ b/tests/browser-auth.test.ts @@ -8,13 +8,13 @@ import { createPreviewSession, localSessionKey, prepareAuthentication, SessionEx type MsalClient, type SessionHost } from '../src/web/session.js'; import { authenticationErrorMessage, renderAuthentication, type AuthElements, type AuthState } from '../src/web/auth-view.js'; -const previewConfig: BrowserConfig = { mode: 'local', capabilities: { files: false, functions: false } }; -const entraConfig: BrowserConfig = { +const previewConfig = { mode: 'local', readinessPath: '/api/AppReady' } satisfies BrowserConfig; +const entraConfig = { mode: 'entra', tenantId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', clientId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', scope: 'api://bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/access_as_user', - capabilities: { files: false, functions: false }, -}; + readinessPath: '/api/AppReady', +} satisfies BrowserConfig; const previewToken = 'A'.repeat(43); const account: AccountInfo = { homeAccountId: 'home', environment: 'login.microsoftonline.com', tenantId: entraConfig.tenantId, @@ -387,10 +387,8 @@ test('authentication errors distinguish expiry, forbidden actions and backend un function browserElement() { return { - hidden: false, disabled: false, textContent: '', children: [] as string[], resets: 0, + hidden: false, disabled: false, textContent: '', onclick: null as (() => void) | null, - replaceChildren() { this.children = []; this.textContent = ''; }, - reset() { this.resets++; }, }; } @@ -406,6 +404,7 @@ function browserGlobal(t: TestContext, name: string, value: unknown) { const settleBrowser = () => new Promise(resolve => setImmediate(resolve)); type BrowserResponse = Response | Error | Promise; let browserImport = 0; +const readinessRows = [{ ready: 1 }]; async function browserApp( t: TestContext, mode: 'local' | 'entra', initialResponses: [string, BrowserResponse][] = [], withSession = true, @@ -416,12 +415,6 @@ async function browserApp( if (!value) { value = browserElement(); elements.set(id, value); } return value; }; - const timers: (() => void)[] = []; - t.mock.method(globalThis, 'setTimeout', (callback: () => void) => { - timers.push(callback); - return {} as ReturnType; - }); - t.mock.method(globalThis, 'clearTimeout', () => {}); const windowHost = { addEventListener: (_event: string, stop: () => void) => { t.after(stop); } }; const msal = new TestMsalClient(); if (!withSession) msal.accounts = []; @@ -432,10 +425,7 @@ async function browserApp( (...args: unknown[]) => Reflect.apply(msal[method], msal, args)); } } - const config: BrowserConfig = { - ...(mode === 'local' ? previewConfig : entraConfig), - capabilities: { files: true, functions: false, processing: true, automaticProgress: true, progressIntervalMs: 1000 }, - }; + const config: BrowserConfig = mode === 'local' ? previewConfig : entraConfig; const responses = new Map(initialResponses); const requests: string[] = []; const fixture = sessionHost(async input => { @@ -450,8 +440,7 @@ async function browserApp( if (response) return response; if (path === '/auth/config') return Response.json(config); if (path === '/auth/me') return Response.json({ oid: 'browser-user', tenantId: 'browser-tenant', access: { allowed: true } }); - if (path === '/storage') return Response.json({ files: [] }); - if (path === '/jobs') return Response.json({ jobs: [] }); + if (path === config.readinessPath) return Response.json({ value: readinessRows }); throw new Error(`Unexpected browser request: ${requestUrl}`); }); if (withSession) fixture.values.set(localSessionKey, previewToken); @@ -459,38 +448,13 @@ async function browserApp( browserGlobal(t, 'sessionStorage', fixture.host.storage); browserGlobal(t, 'window', mode === 'local' ? windowHost : undefined); browserGlobal(t, 'BroadcastChannel', undefined); - browserGlobal(t, 'document', { - getElementById: element, visibilityState: 'visible', - querySelectorAll: () => [element('refresh-files'), element('refresh-jobs')], - }); + browserGlobal(t, 'document', { getElementById: element, visibilityState: 'visible' }); t.mock.method(globalThis, 'fetch', fixture.host.fetch); await import(`../src/web/app.js?browser-test=${++browserImport}`); await settleBrowser(); - return { - element, responses, requests, values: fixture.values, msal, - poll: async () => { - const tick = timers.shift(); - assert.ok(tick, 'automatic progress should be scheduled'); - tick(); - await settleBrowser(); - }, - }; + return { element, responses, requests, values: fixture.values, msal }; } -test('browser loads file pages only when the user asks for more', async t => { - const app = await browserApp(t, 'local', [ - ['/storage', Response.json({ files: [], continuationToken: 'next page' })], - ['/storage?continuationToken=next+page', Response.json({ files: [] })], - ]); - assert.equal(app.element('load-more-files').hidden, false); - assert.deepEqual(app.requests.filter(path => path.startsWith('/storage')), ['/storage']); - app.element('load-more-files').onclick!(); - await settleBrowser(); - assert.deepEqual(app.requests.filter(path => path.startsWith('/storage')), - ['/storage', '/storage?continuationToken=next+page']); - assert.equal(app.element('load-more-files').hidden, true); -}); - function pendingBrowserResponse() { let resolve!: (response: Response) => void; let reject!: (error: Error) => void; @@ -498,6 +462,11 @@ function pendingBrowserResponse() { return { promise, resolve, reject }; } +async function checkData(app: Awaited>) { + app.element('check-data').onclick!(); + await settleBrowser(); +} + function assertExpiredBrowser(app: Awaited>, mode: 'local' | 'entra') { const message = mode === 'local' ? 'Local preview expired. Open it again.' : 'Your session expired. Sign in again.'; assert.equal(app.element('auth-status').textContent, message); @@ -507,10 +476,10 @@ function assertExpiredBrowser(app: Awaited>, mode: assert.equal(app.element('auth-retry').hidden, true); assert.equal(app.element('logout').hidden, true); assert.equal(app.element('workspace').hidden, true); + assert.equal(app.element('data-result').textContent, ''); assert.equal(app.values.has(localSessionKey), false); - if (mode === 'entra') assert.equal(app.msal.activeAccount, null); - for (const id of ['files', 'jobs', 'traces']) assert.deepEqual(app.element(id).children, []); - assert.equal(app.element('function-result').textContent, ''); + if (mode === 'local') return; + assert.equal(app.msal.activeAccount, null); } const browserFailures = [ @@ -520,76 +489,63 @@ const browserFailures = [ ]; for (const mode of ['local', 'entra'] as const) { + const signedIn = mode === 'local' ? 'Local preview is open.' : `Signed in as ${account.username}`; + + test(`${mode} authorized browser checks the readiness procedure once and shows its rows`, async t => { + const app = await browserApp(t, mode); + assert.deepEqual(app.requests, ['/auth/config', '/auth/me', '/api/AppReady']); + assert.equal(app.element('workspace').hidden, false); + assert.equal(app.element('status').textContent, signedIn); + assert.equal(app.element('data-result').textContent, JSON.stringify(readinessRows, null, 2)); + assert.equal(app.element('check-data').disabled, false); + await checkData(app); + assert.deepEqual(app.requests.slice(3), ['/api/AppReady']); + }); + + test(`${mode} browser ignores repeated data checks while one is pending`, async t => { + const app = await browserApp(t, mode); + const pending = pendingBrowserResponse(); + t.after(() => pending.resolve(Response.json({ value: [] }))); + app.responses.set('/api/AppReady', pending.promise); + await checkData(app); + assert.equal(app.element('check-data').disabled, true); + assert.equal(app.element('status').textContent, 'Checking data...'); + await checkData(app); + assert.deepEqual(app.requests.slice(3), ['/api/AppReady']); + pending.resolve(Response.json({ value: [] })); + await settleBrowser(); + assert.equal(app.element('check-data').disabled, false); + assert.equal(app.element('data-result').textContent, '[]'); + }); + for (const failure of [ - ...browserFailures, - { name: 'repeated 401', response: () => new Response(null, { status: 401 }) }, - { name: 'repeated session expiry', response: () => new SessionExpiredError('Session ended') }, + { name: '401', response: () => new Response(null, { status: 401 }) }, + { name: 'session expiry', response: () => new SessionExpiredError('Session ended') }, ]) { - test(`${mode} browser preserves expired recovery after ordered jobs 401 then storage ${failure.name}`, async t => { + test(`${mode} browser expires the session and clears data after a data ${failure.name}`, async t => { const app = await browserApp(t, mode); - const jobs = pendingBrowserResponse(); - const files = pendingBrowserResponse(); - t.after(() => { jobs.resolve(new Response(null, { status: 401 })); files.resolve(new Response(null, { status: 503 })); }); - app.responses.set('/jobs', jobs.promise); - app.responses.set('/storage', files.promise); - for (const id of ['files', 'jobs', 'traces']) app.element(id).children.push('owner data'); - app.element('function-result').textContent = 'owner result'; - const resets = app.element('file-form').resets; - await app.poll(); - app.element('refresh-files').onclick!(); - await settleBrowser(); - assert.deepEqual(app.requests.slice(-2), ['/jobs', '/storage']); - jobs.resolve(new Response(null, { status: 401 })); - await settleBrowser(); - assertExpiredBrowser(app, mode); - assert.ok(app.element('file-form').resets > resets); - const response = failure.response(); - if (response instanceof Error) files.reject(response); - else files.resolve(response); - await settleBrowser(); + app.responses.set('/api/AppReady', failure.response()); + await checkData(app); assertExpiredBrowser(app, mode); const requests = app.requests.length; - await app.poll(); - assert.equal(app.requests.length, requests, 'expired sessions must not resume polling'); + await checkData(app); + assert.equal(app.requests.length, requests, 'expired sessions must not check data again'); }); } - test(`${mode} browser ignores stale polling availability after storage expires the session`, async t => { - const app = await browserApp(t, mode); - const jobs = pendingBrowserResponse(); - const files = pendingBrowserResponse(); - t.after(() => { jobs.resolve(new Response(null, { status: 503 })); files.resolve(new Response(null, { status: 401 })); }); - app.responses.set('/jobs', jobs.promise); - app.responses.set('/storage', files.promise); - await app.poll(); - app.element('refresh-files').onclick!(); - await settleBrowser(); - assert.deepEqual(app.requests.slice(-2), ['/jobs', '/storage']); - files.resolve(new Response(null, { status: 401 })); - await settleBrowser(); - assertExpiredBrowser(app, mode); - const progress = app.element('job-progress').textContent; - jobs.resolve(new Response(null, { status: 503 })); - await settleBrowser(); - assert.equal(app.element('job-progress').textContent, progress); - assertExpiredBrowser(app, mode); - }); - for (const failure of browserFailures) { - test(`${mode} authorized browser reports storage ${failure.name} without losing the account or workspace`, async t => { + test(`${mode} authorized browser reports data ${failure.name} without losing the account or workspace`, async t => { const app = await browserApp(t, mode); const accountStatus = app.element('auth-status').textContent; - app.element('files').children.push('owner data'); - app.responses.set('/storage', failure.response()); - app.element('refresh-files').onclick!(); - await settleBrowser(); + app.responses.set('/api/AppReady', failure.response()); + await checkData(app); assert.equal(app.element('auth-status').textContent, accountStatus); assert.equal(app.element('status').textContent, failure.message); assert.equal(app.element('workspace').hidden, false); assert.equal(app.element('login').hidden, true); assert.equal(app.element('logout').hidden, false); assert.equal(app.element('auth-retry').hidden, true); - assert.deepEqual(app.element('files').children, ['owner data']); + assert.equal(app.element('check-data').disabled, false); if (mode === 'local') assert.equal(app.values.has(localSessionKey), true); else assert.equal(app.msal.activeAccount, account); }); @@ -609,23 +565,6 @@ for (const mode of ['local', 'entra'] as const) { } } - test(`${mode} authorized browser keeps polling after a backend availability failure`, async t => { - const app = await browserApp(t, mode); - const accountStatus = app.element('auth-status').textContent; - app.responses.set('/jobs', new Response(null, { status: 503 })); - await app.poll(); - assert.equal(app.element('auth-status').textContent, accountStatus); - assert.equal(app.element('workspace').hidden, false); - assert.equal(app.element('status').textContent, 'Backend service is unavailable (503). Try again.'); - assert.equal(app.element('job-progress').textContent, - 'Automatic progress is unavailable; retrying with backoff. Check local services.'); - const requests = app.requests.length; - app.responses.delete('/jobs'); - await app.poll(); - assert.equal(app.requests.length, requests + 1); - assert.equal(app.element('job-progress').textContent, 'Job progress updates automatically.'); - }); - test(`${mode} browser preserves access denial without offering an availability retry`, async t => { const app = await browserApp(t, mode, [['/auth/me', Response.json({ oid: 'browser-user', tenantId: 'browser-tenant', access: { allowed: false, requiredRole: 'app-user' }, @@ -660,26 +599,23 @@ for (const mode of ['local', 'entra'] as const) { test(`${mode} browser preserves expired recovery when a pending sign-out failure arrives after expiry`, async t => { const app = await browserApp(t, mode); - const jobs = pendingBrowserResponse(); + const data = pendingBrowserResponse(); const signOut = pendingBrowserResponse(); - t.after(() => { jobs.resolve(new Response(null, { status: 401 })); signOut.resolve(new Response(null, { status: 503 })); }); - app.responses.set('/jobs', jobs.promise); + t.after(() => { data.resolve(new Response(null, { status: 401 })); signOut.resolve(new Response(null, { status: 503 })); }); + app.responses.set('/api/AppReady', data.promise); if (mode === 'local') app.responses.set('/local/session', signOut.promise); else t.mock.method(app.msal, 'logoutRedirect', async () => { await signOut.promise; }); - await app.poll(); + await checkData(app); app.element('logout').onclick!(); await settleBrowser(); assert.equal(app.element('auth-status').textContent, mode === 'local' ? 'Ending local preview...' : 'Signing out...'); - jobs.resolve(new Response(null, { status: 401 })); + data.resolve(new Response(null, { status: 401 })); await settleBrowser(); assertExpiredBrowser(app, mode); if (mode === 'local') signOut.resolve(new Response(null, { status: 503 })); else signOut.reject(new Error('Microsoft sign-out failed')); await settleBrowser(); assertExpiredBrowser(app, mode); - const requests = app.requests.length; - await app.poll(); - assert.equal(app.requests.length, requests, 'expired sessions must not resume polling'); }); } @@ -700,13 +636,13 @@ test('browser restores signed-out recovery after Microsoft login cancellation', }); test('product markup exposes semantic account lifecycle controls without a development persona selector', async () => { - const html = await readFile('public\\index.html', 'utf8'); + const html = await readFile('public/index.html', 'utf8'); assert.match(html, /

/); assert.match(html, /id="auth-notice" hidden/); assert.match(html, /id="login" hidden>Sign in with MicrosoftTry againLoad more filesCheck data { for (const config of [ - { mode: 'entra', tenantId: 'tenant', clientId: 'client', scope: 'scope', capabilities: { files: true, functions: true } }, - { mode: 'local', capabilities: { files: false, functions: false } }, + { mode: 'entra', tenantId: 'tenant', clientId: 'client', scope: 'scope', readinessPath }, + { mode: 'local', readinessPath }, ]) assert.deepEqual(parseBrowserConfig(config), config); - assert.equal('users' in parseBrowserConfig({ mode: 'local', capabilities: { files: false, functions: false } }), false); - assert.deepEqual(parseBrowserConfig({ tenantId: 'tenant', clientId: 'client', scope: 'scope' }), - { mode: 'entra', tenantId: 'tenant', clientId: 'client', scope: 'scope', capabilities: { files: true, functions: true } }); + assert.equal('users' in parseBrowserConfig({ mode: 'local', readinessPath, users: ['alice'] }), false); for (const config of [ - null, {}, { mode: 'unknown', capabilities: { files: true, functions: true } }, - { mode: 'local' }, - { mode: 'local', capabilities: { files: false } }, - { mode: 'local', capabilities: { files: false, functions: 'yes' } }, - { mode: 'entra', clientId: 'client', capabilities: { files: 'yes', functions: false } }, + null, {}, { mode: 'unknown', readinessPath }, { mode: 'local' }, + { tenantId: 'tenant', clientId: 'client', scope: 'scope', readinessPath }, + { mode: 'entra', clientId: 'client', scope: 'scope', readinessPath }, ]) assert.throws(() => parseBrowserConfig(config), /Invalid/); }); -test('processing controls require an explicit boolean capability', () => { - const config = { mode: 'local', - capabilities: { files: true, functions: true, processing: true } }; - assert.deepEqual(parseBrowserConfig(config), config); - assert.throws(() => parseBrowserConfig({ ...config, capabilities: { ...config.capabilities, processing: 'yes' } }), /Invalid/); - assert.equal(parseBrowserConfig({ ...config, capabilities: { files: true, functions: true } }).capabilities.processing, undefined); -}); - -test('browser service and optional capabilities preserve booleans and reject other values', () => { - for (const capability of ['files', 'functions', 'processing', 'jobSubmission', 'jobRetry', 'automaticProgress', 'tracing']) { - for (const value of [false, true]) { - const config = { mode: 'local', capabilities: { files: false, functions: false, [capability]: value } }; - assert.deepEqual(parseBrowserConfig(config), config); - } - for (const value of [undefined, null, 'true', 1, {}, []]) { - assert.throws(() => parseBrowserConfig({ - mode: 'local', capabilities: { files: false, functions: false, [capability]: value }, - }), /Invalid/); - } +test('the readiness path must name one DAB REST entity', () => { + for (const path of ['/api/AppReady', '/api/Ready_2']) { + assert.equal(parseBrowserConfig({ mode: 'local', readinessPath: path }).readinessPath, path); + } + for (const path of ['', '/api/', '/graphql', '/api/A/B', 'https://evil.example/api/A', '/api/A?x=1', 1]) { + assert.throws(() => parseBrowserConfig({ mode: 'local', readinessPath: path }), /Invalid browser readiness path/); } }); test('Entra browser configuration preserves the explicit local backend flag', () => { - const config = { mode: 'entra', tenantId: 'tenant', clientId: 'client', scope: 'scope', - capabilities: { files: false, functions: false } }; + const config = { mode: 'entra', tenantId: 'tenant', clientId: 'client', scope: 'scope', readinessPath }; assert.deepEqual(parseBrowserConfig(config), config); for (const localBackend of [false, true]) { assert.deepEqual(parseBrowserConfig({ ...config, localBackend }), { ...config, localBackend }); @@ -51,9 +35,8 @@ test('Entra browser configuration preserves the explicit local backend flag', () }); test('Entra browser configuration rejects nonboolean local backend flags', () => { - const config = { mode: 'entra', tenantId: 'tenant', clientId: 'client', scope: 'scope', - capabilities: { files: false, functions: false } }; + const config = { mode: 'entra', tenantId: 'tenant', clientId: 'client', scope: 'scope', readinessPath }; for (const localBackend of [undefined, null, 'true', 1, {}, []]) { assert.throws(() => parseBrowserConfig({ ...config, localBackend }), /Invalid local backend configuration/); } -}); +}); \ No newline at end of file diff --git a/tests/client-pagination.test.ts b/tests/client-pagination.test.ts deleted file mode 100644 index 25e0a84..0000000 --- a/tests/client-pagination.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { SqlAppsClient } from '../src/client.js'; - -test('file listing reads exactly one page and forwards only the requested continuation', async () => { - const requests: string[] = []; - const client = new SqlAppsClient({ - baseUrl: 'https://app.example/', - getAccessToken: async () => 'token', - fetch: async input => { - const url = new URL(String(input)); - requests.push(url.pathname + url.search); - return Response.json(url.search - ? { files: [{ name: 'second.txt', size: 2 }] } - : { files: [{ name: 'first.txt', size: 1 }], continuationToken: 'opaque marker' }); - }, - }); - assert.deepEqual(await client.listFiles(), { - files: [{ name: 'first.txt', size: 1 }], continuationToken: 'opaque marker', - }); - assert.deepEqual(requests, ['/storage']); -}); diff --git a/tests/contracts.test.ts b/tests/contracts.test.ts index 21d9eb6..a49fafe 100644 --- a/tests/contracts.test.ts +++ b/tests/contracts.test.ts @@ -1,54 +1,42 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; -import { fileJobSchema } from '../src/file-jobs.js'; - -test('file job responses enforce the SQL filename limit in UTF-16 units', () => { - const row = { id: '11111111-1111-4111-8111-111111111111', filename: '', - status: 'queued', sha256: null, byte_count: null, line_count: null, error: null }; - for (const filename of ['x'.repeat(128), String.fromCodePoint(0x1F33F).repeat(64)]) { - assert.equal(fileJobSchema.parse({ ...row, filename }).filename, filename); - assert.equal(fileJobSchema.safeParse({ ...row, filename: filename + 'x' }).success, false); - } -}); test('starter deployment names use sqlapps for generated URL prefixes', async () => { const config = JSON.parse(await readFile('sql-apps.example.json', 'utf8')); assert.equal(config.name, 'sqlapps'); + assert.equal(config.profile, 'role-based-data'); + assert.equal(config.readinessPath, '/api/AppReady'); + assert.notEqual(config.location, 'eastus'); + assert.equal('functionsImage' in config, false); const template = await readFile('infra/main.bicep', 'utf8'); assert.match(template, /param name string = 'sqlapps'/); assert.ok(template.includes("var prefix = '${name}-${environment}'")); assert.ok(template.includes("output gatewayName string = '${prefix}-gateway'")); - assert.ok(template.includes("var functionName = '${prefix}-fn-${suffix}'")); + assert.doesNotMatch(template, /Microsoft\.Web\/sites|Microsoft\.Storage|Microsoft\.KeyVault/); }); -test('DAB requires AzureAD, authenticated permissions, ownership and immutable owner fields', async () => { +test('DAB starter exposes only the AppUser readiness procedure with AzureAD auth', async () => { const config = JSON.parse(await readFile('dab/dab-config.json', 'utf8')); assert.equal(config['data-source']['connection-string'], "@env('SQL_CONNECTION_STRING')"); assert.equal(config['data-source'].options['set-session-context'], true); assert.equal(config.runtime.host.authentication.provider, 'AzureAD'); assert.equal(config.runtime.mcp.enabled, false); assert.equal(config.runtime.pagination['next-link-relative'], true); - assert.deepEqual(config.entities.FileJob.graphql, { enabled: true }); - for (const permission of config.entities.FileJob.permissions) { - assert.equal(permission.role, 'authenticated'); - for (const action of permission.actions) { - if (action.action === 'create' || action.action === 'update') assert.equal(action.fields.include.includes('owner_oid'), false); - if (action.action === 'read' || action.action === 'delete') assert.equal(action.policy.database, '@item.owner_oid eq @claims.oid'); - if (action.action === 'update') assert.equal(action.policy, undefined); - } - } + assert.deepEqual(Object.keys(config.entities), ['AppReady']); + assert.deepEqual(config.entities.AppReady.source, { object: 'dbo.AppReady', type: 'stored-procedure' }); + assert.deepEqual(config.entities.AppReady.permissions, [{ role: 'AppUser', actions: ['execute'] }]); }); -test('SQL enforces ownership on reads and writes independently of HTTP input', async () => { - const sql = await readFile('sql/migrations/002-file-jobs.sql', 'utf8'); - assert.match(sql, /DEFAULT CONVERT\(nvarchar\(36\), SESSION_CONTEXT\(N'oid'\)\)/); - assert.match(sql, /ADD FILTER PREDICATE/); - assert.match(sql, /AFTER INSERT/); - assert.match(sql, /AFTER UPDATE/); + +test('SQL runtime grants require the generated procedure grants and bind DAB by SID', async () => { + const procedure = await readFile('sql/procedures/AppReady.sql', 'utf8'); + assert.match(procedure, /CREATE PROCEDURE dbo\.AppReady/); const grants = await readFile('sql/grant-runtime.sql', 'utf8'); - assert.match(grants, /GRANT VIEW DEFINITION ON dbo\.FileJobs TO \[sql_apps_dab\]/); - assert.match(grants, /GRANT SELECT ON dbo\.FileJobs TO \[sql_apps_dab\]/); assert.match(grants, /DATABASE_PRINCIPAL_ID\(N'sql_apps_dab'\)/); assert.match(grants, /CREATE USER \[sql_apps_dab\] WITH SID/); - assert.ok((await readFile('sql/database.sqlproj', 'utf8')).includes('SqlApps')); -}); + assert.match(grants, /THROW 50003/); + assert.doesNotMatch(grants, /db_datareader|db_datawriter|db_owner/); + const project = await readFile('sql/database.sqlproj', 'utf8'); + assert.ok(project.includes('SqlApps')); + assert.ok(project.includes('')); +}); \ No newline at end of file diff --git a/tests/demo-cost.test.ts b/tests/demo-cost.test.ts deleted file mode 100644 index 30ee599..0000000 --- a/tests/demo-cost.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'; -import { resolve, join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { reviewDemoCost } from '../src/demo-cost.js'; -import { run } from '../src/process.js'; - -const free = { - version: 1, profile: 'public-demo', intent: 'zero-azure-spend', - sql: { mode: 'free-paused' }, acknowledgeFixedCharges: false, -}; - -test('zero-spend review fails closed on registry/network charges despite SQL/compute grants', () => { - const report = reviewDemoCost(free); - assert.equal(report.hosting, 'container-apps'); - assert.equal(report.review.status, 'blocked-by-fixed-charges'); - assert.equal(report.review.canProceedToWhatIf, false); - assert.equal(report.deployment.authorized, false); - assert.equal(report.estimatedMonthlyCost, null); - assert.equal(report.fixedCharges.length, 2); - assert.equal(report.sql.parameters.sqlBillingMode, 'free-paused'); - assert.equal(report.sql.exhaustionBehavior, 'AutoPause'); - assert.equal(report.sql.parameters.sqlMaxSizeBytes, 32 * 1024 ** 3); - assert.equal(report.sql.parameters.sqlBackupRedundancy, 'Local'); - assert.deepEqual(report.sql.parameters.sqlSku, { name: 'GP_S_Gen5_2', tier: 'GeneralPurpose', family: 'Gen5', capacity: 2 }); - assert.ok(report.sql.allowance); - assert.equal(report.sql.allowance.computeVCoreSeconds, 100000); - assert.equal(report.compute.allowance.vCpuSeconds, 180000); - assert.equal(report.compute.allowance.memoryGiBSeconds, 360000); - assert.equal(report.compute.allowance.requests, 2000000); - assert.equal(report.compute.resources.vCpu, 0.75); - assert.equal(report.compute.resources.memoryGiB, 1.5); - assert.ok(Math.abs(report.compute.theoreticalActiveReplicaHours - 66.6666666667) < 0.000001); - assert.equal(report.compute.actualUsage, 'not-queried'); - assert.equal(report.sql.actualUsage, 'not-queried'); - assert.match(report.monitoring.budgetWarning, /not.*cap/i); - assert.match(report.sql.warning, /cannot revert/i); - assert.match(report.references.note, /Free tier.*recurring monthly allowances.*not time-bound trial credits/); -}); - -test('Static Web Apps reports its Standard fixed charge without estimating a price', () => { - const report = reviewDemoCost({ ...free, hosting: 'static-web-apps' }); - assert.equal(report.hosting, 'static-web-apps'); - assert.equal(report.estimatedMonthlyCost, null); - assert.equal(report.fixedCharges.length, 3); - assert.deepEqual(report.fixedCharges.at(-1), { - resource: 'Azure Static Web Apps Standard', - reason: 'A linked Container Apps backend requires Standard; the Container App, ACR, SQL, and networking remain.', - pricing: 'https://azure.microsoft.com/pricing/details/app-service/static/', - }); -}); - -test('paid candidate cost guidance includes the Static Web Apps Standard charge', async () => { - const reference = await readFile('docs/reference/demo-cost.md', 'utf8'); - assert.match(reference, /Separately reviewing paid supporting resources[\s\S]*"hosting": "static-web-apps"/); -}); - -test('paid cost review requires an explicit acknowledgement even when SQL stays free-paused', () => { - assert.equal(reviewDemoCost({ ...free, intent: 'review-paid-costs' }).review.status, 'fixed-charge-acknowledgement-required'); - const report = reviewDemoCost({ ...free, intent: 'review-paid-costs', acknowledgeFixedCharges: true }); - assert.equal(report.review.status, 'ready-for-target-cost-review'); - assert.equal(report.review.canProceedToWhatIf, true); - assert.equal(report.deployment.authorized, false); - assert.equal(report.estimatedMonthlyCost, null); - assert.equal(report.sql.exhaustionBehavior, 'AutoPause'); -}); - -test('ordinary paid SQL is separate from irreversible free-tier paid continuation', () => { - const report = reviewDemoCost({ - ...free, intent: 'review-paid-costs', acknowledgeFixedCharges: true, - sql: { - mode: 'paid-reviewed', sku: { name: 'S0', tier: 'Standard', capacity: 10 }, - maxSizeBytes: 250 * 1024 ** 3, backupRedundancy: 'Geo', - }, - }); - assert.equal(report.sql.parameters.sqlBillingMode, 'paid-reviewed'); - assert.equal(report.sql.parameters.sqlSku.name, 'S0'); - assert.equal(report.sql.exhaustionBehavior, 'not-a-free-tier'); - assert.equal(report.sql.allowance, null); - assert.match(report.sql.warning, /existing.*database/i); -}); - -test('invalid cost choices fail before approval or cloud access and never imply paid fallback', () => { - for (const input of [ - { ...free, profile: 'authenticated-application' }, - { ...free, hosting: 'free' }, - { ...free, intent: 'free-trial' }, - { ...free, maxMonthlyCost: 0 }, - { ...free, acknowledgeFixedCharges: 'true' }, - { ...free, acknowledgeFixedCharges: true }, - { ...free, sql: { mode: 'BillOverUsage' } }, - { ...free, sql: { mode: 'free-paused', exhaustionBehavior: 'BillOverUsage' } }, - { ...free, sql: { mode: 'free-paused', maxSizeBytes: 64 * 1024 ** 3 } }, - { ...free, sql: { mode: 'paid-reviewed' } }, - { ...free, intent: 'review-paid-costs', sql: { mode: 'paid-reviewed', - sku: { name: 'S0', tier: 'Standard', capacity: -1 }, maxSizeBytes: 1024, backupRedundancy: 'Local' } }, - { ...free, sql: { mode: 'paid-reviewed', - sku: { name: 'S0', tier: 'Standard', capacity: 10 }, maxSizeBytes: 1024 ** 3, backupRedundancy: 'Local' } }, - ]) assert.throws(() => reviewDemoCost(input)); -}); - -test('example and actual CLI show an honest offline blocked report with a nonzero exit', async () => { - const example = JSON.parse(await readFile('azure-demo-cost.example.json', 'utf8')); - assert.deepEqual(example, free); - assert.equal(reviewDemoCost(example).review.status, 'blocked-by-fixed-charges'); - await assert.rejects(run(process.execPath, [resolve('dist', 'src', 'cli.js'), 'demo-cost', - resolve('azure-demo-cost.example.json')]), error => - error instanceof Error && /failed \(2\)/.test(error.message) && - /blocked-by-fixed-charges/.test(error.message) && /"authorized": false/.test(error.message)); - await assert.rejects(run(process.execPath, [resolve('dist', 'src', 'cli.js'), 'demo-cost']), - /explicit.*cost.*azure-demo-cost\.example\.json.*azure-role-based-cost\.example\.json/is); -}); - -test('actual CLI can review acknowledged costs but never reports deployment or a price quote', async t => { - const directory = await mkdtemp(join(tmpdir(), 'sql-apps-cost-')); - t.after(() => rm(directory, { recursive: true, force: true })); - const path = join(directory, 'cost review.json'); - await writeFile(path, JSON.stringify({ ...free, intent: 'review-paid-costs', acknowledgeFixedCharges: true })); - const output = JSON.parse(await run(process.execPath, [resolve('dist', 'src', 'cli.js'), 'demo-cost', path])); - assert.equal(output.review.status, 'ready-for-target-cost-review'); - assert.equal(output.deployment.authorized, false); - assert.equal(output.deployment.ready, false); - assert.equal(output.estimatedMonthlyCost, null); - await writeFile(path, JSON.stringify({ ...free, sql: { mode: 'BillOverUsage' } })); - await assert.rejects(run(process.execPath, [resolve('dist', 'src', 'cli.js'), 'demo-cost', path]), /failed \(1\)/); -}); diff --git a/tests/demo-infra.test.mjs b/tests/demo-infra.test.mjs deleted file mode 100644 index 726f96c..0000000 --- a/tests/demo-infra.test.mjs +++ /dev/null @@ -1,168 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { access, mkdtemp, readFile, rm } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; -import { tmpdir } from 'node:os'; -import { run } from '../dist/src/process.js'; -import { reviewDemoCost } from '../dist/src/demo-cost.js'; - -async function compile(t, name) { - await assert.doesNotReject(access(resolve('infra', `${name}.bicep`)), `${name} template must exist`); - const directory = await mkdtemp(join(tmpdir(), 'sql-apps-demo-infra-')); - t.after(() => rm(directory, { recursive: true, force: true })); - const output = join(directory, `${name}.json`); - await run('az', ['bicep', 'build', '--file', resolve('infra', `${name}.bicep`), '--outfile', output]); - return JSON.parse(await readFile(output, 'utf8')); -} - -test('compiled minimal foundation has reviewed SQL sizing, narrow networking and no unrelated services', async t => { - const template = await compile(t, 'demo'); - assert.equal(template.parameters.name.defaultValue, 'sqlapps'); - const resources = template.resources; - assert.deepEqual(resources.map(resource => resource.type).sort(), [ - 'Microsoft.App/managedEnvironments', - 'Microsoft.ContainerRegistry/registries', - 'Microsoft.Insights/diagnosticSettings', - 'Microsoft.ManagedIdentity/userAssignedIdentities', - 'Microsoft.Network/virtualNetworks', - 'Microsoft.OperationalInsights/workspaces', - 'Microsoft.Sql/servers', - 'Microsoft.Sql/servers/databases', - 'Microsoft.Sql/servers/virtualNetworkRules', - ].sort()); - for (const field of ['sqlBillingMode', 'sqlSku', 'sqlMaxSizeBytes', 'sqlBackupRedundancy', 'location', 'tenantId', 'deploymentId', 'tags', 'securityPosture']) { - assert.equal(Object.hasOwn(template.parameters[field], 'defaultValue'), false, `${field} must be explicit`); - } - const database = resources.find(resource => resource.type === 'Microsoft.Sql/servers/databases'); - assert.deepEqual(template.parameters.sqlBillingMode.allowedValues, ['free-paused', 'paid-reviewed']); - assert.equal(template.variables.freeSql, "[equals(parameters('sqlBillingMode'), 'free-paused')]"); - assert.deepEqual(template.variables.freeSqlSku, { name: 'GP_S_Gen5_2', tier: 'GeneralPurpose', family: 'Gen5', capacity: 2 }); - assert.equal(database.sku, "[if(variables('freeSql'), variables('freeSqlSku'), parameters('sqlSku'))]"); - assert.match(database.properties, /useFreeLimit.*variables\('freeSql'\)/); - assert.match(database.properties, /maxSizeBytes.*34359738368.*sqlMaxSizeBytes/); - assert.match(database.properties, /requestedBackupStorageRedundancy.*Local.*sqlBackupRedundancy/); - assert.match(database.properties, /if\(variables\('freeSql'\).*freeLimitExhaustionBehavior.*AutoPause.*autoPauseDelay.*60.*minCapacity.*0\.5/); - assert.doesNotMatch(database.properties, /BillOverUsage|BillForUsage/); - const sql = resources.find(resource => resource.type === 'Microsoft.Sql/servers'); - assert.equal(sql.properties.publicNetworkAccess, 'Enabled'); - assert.equal(sql.properties.minimalTlsVersion, '1.2'); - assert.equal(sql.properties.administrators.azureADOnlyAuthentication, true); - assert.equal(Object.hasOwn(sql.properties, 'administratorLoginPassword'), false); - const network = resources.find(resource => resource.type === 'Microsoft.Network/virtualNetworks'); - assert.equal(network.properties.subnets.length, 1); - assert.deepEqual(network.properties.subnets[0].properties.serviceEndpoints.map(endpoint => endpoint.service), ['Microsoft.Sql']); - assert.equal(network.properties.subnets[0].properties.addressPrefix, '10.43.0.0/27'); - const rule = resources.find(resource => resource.type === 'Microsoft.Sql/servers/virtualNetworkRules'); - assert.equal(rule.properties.ignoreMissingVnetServiceEndpoint, false); - const registry = resources.find(resource => resource.type === 'Microsoft.ContainerRegistry/registries'); - assert.equal(registry.sku.name, 'Basic', 'Cost review must reflect the actual fixed-charge registry'); - assert.equal(registry.name, "[parameters('registryName')]"); - assert.equal(Object.hasOwn(template.parameters.registryName, 'defaultValue'), false); - for (const key of ['sql-apps-deployment', 'sql-apps-application', 'sql-apps-profile']) { - assert.ok(JSON.stringify(template.variables.ownedTags).includes(key), `Missing ownership tag ${key}`); - } - assert.equal(registry.properties.adminUserEnabled, "[variables('starterRegistry')]"); - assert.deepEqual(template.parameters.securityPosture.allowedValues, ['starter', 'hardened']); - assert.equal(template.variables.starterRegistry, "[equals(parameters('securityPosture'), 'starter')]"); - assert.equal(resources.some(resource => resource.type === 'Microsoft.Authorization/roleAssignments'), false); - assert.equal(registry.properties.policies.azureADAuthenticationAsArmPolicy.status, 'enabled'); - const environment = resources.find(resource => resource.type === 'Microsoft.App/managedEnvironments'); - assert.deepEqual(environment.properties.workloadProfiles, [{ name: 'Consumption', workloadProfileType: 'Consumption' }]); - assert.equal(environment.properties.appLogsConfiguration.destination, 'azure-monitor'); - const workspace = resources.find(resource => resource.type === 'Microsoft.OperationalInsights/workspaces'); - assert.equal(workspace.properties.sku.name, 'PerGB2018'); - assert.equal(workspace.properties.workspaceCapping.dailyQuotaGb, '[json(\'0.1\')]', - 'The demo workspace must stay inside the free daily ingestion allowance'); - assert.equal(workspace.properties.retentionInDays, "[parameters('logRetentionDays')]"); - assert.equal(template.parameters.logRetentionDays.defaultValue, 30); - assert.equal(template.parameters.logRetentionDays.maxValue, 90, - 'Retention beyond the included 31 days is billable; keep the reviewed ceiling'); - assert.equal(workspace.properties.features.disableLocalAuth, true); - const diagnostics = resources.find(resource => resource.type === 'Microsoft.Insights/diagnosticSettings'); - assert.match(JSON.stringify(diagnostics.properties.workspaceId), /workspaces/); - assert.deepEqual(diagnostics.properties.logs, [{ categoryGroup: 'allLogs', enabled: true }]); - for (const key of ['runtimeIdentityResourceId', 'runtimeIdentityClientId', 'runtimeIdentityPrincipalId', 'sqlServerId', 'databaseId', 'subnetId', 'registryId', 'logsWorkspaceId']) { - assert.ok(template.outputs[key], `Missing exact tracked resource output ${key}`); - } - assert.doesNotMatch(JSON.stringify(template), /0\.0\.0\.0|listCredentials|Microsoft\.Graph|Microsoft\.Web|Microsoft\.Storage|privateEndpoints/); -}); - -test('compiled runtime exposes an SWA-linked HTTPS gateway with internal DAB and one scale-to-zero replica', async t => { - const template = await compile(t, 'demo-runtime'); - assert.equal(template.resources.length, 3); - const staticSite = template.resources.find(resource => resource.type === 'Microsoft.Web/staticSites'); - assert.ok(staticSite); - assert.equal(Object.hasOwn(template.parameters.staticWebAppLocation, 'defaultValue'), false); - assert.equal(staticSite.location, "[parameters('staticWebAppLocation')]"); - assert.equal(staticSite.sku.name, 'Standard'); - assert.equal(staticSite.sku.tier, 'Standard'); - assert.equal(staticSite.properties.allowConfigFileUpdates, true); - assert.equal(staticSite.properties.stagingEnvironmentPolicy, 'Disabled'); - const linked = template.resources.find(resource => resource.type === 'Microsoft.Web/staticSites/linkedBackends'); - assert.ok(linked); - assert.match(linked.properties.backendResourceId, /Microsoft\.App\/containerApps/); - assert.equal(linked.properties.region, "[parameters('location')]"); - const app = template.resources.find(resource => resource.type === 'Microsoft.App/containerApps'); - assert.ok(app); - assert.equal(app.type, 'Microsoft.App/containerApps'); - assert.equal(app.properties.workloadProfileName, 'Consumption'); - assert.equal(app.properties.configuration.ingress.external, true); - assert.equal(app.properties.configuration.ingress.allowInsecure, false); - assert.equal(app.properties.configuration.ingress.targetPort, 8080); - assert.equal(Object.hasOwn(app.properties.configuration.ingress, 'additionalPortMappings'), false); - assert.equal(app.properties.configuration.activeRevisionsMode, 'Single'); - assert.deepEqual(template.parameters.securityPosture.allowedValues, ['starter', 'hardened']); - assert.equal(template.variables.starterRegistry, "[equals(parameters('securityPosture'), 'starter')]"); - const registryConfiguration = JSON.stringify({ - secrets: app.properties.configuration.secrets, - registries: app.properties.configuration.registries, - }); - assert.match(registryConfiguration, /listCredentials/); - assert.match(registryConfiguration, /registry-password/); - assert.match(registryConfiguration, /passwordSecretRef/); - assert.match(registryConfiguration, /runtimeIdentity/); - assert.match(app.properties.configuration.registries, - /starterRegistry.*passwordSecretRef.*registry-password.*identity.*runtimeIdentityName/); - assert.doesNotMatch(JSON.stringify(template.parameters), /password|credential/i); - assert.doesNotMatch(JSON.stringify(template.outputs), /password|credential/i); - assert.equal(app.properties.template.scale.minReplicas, 0); - assert.equal(app.properties.template.scale.maxReplicas, 1); - assert.deepEqual(app.properties.template.containers.map(container => container.name), ['gateway', 'data']); - const gateway = app.properties.template.containers[0]; - const data = app.properties.template.containers[1]; - const costs = reviewDemoCost(JSON.parse(await readFile('azure-demo-cost.example.json', 'utf8'))); - for (const selected of costs.compute.containers) { - const container = app.properties.template.containers.find(container => container.name === selected.name); - assert.ok(container, `Missing reviewed container ${selected.name}`); - assert.equal(container.resources.cpu, `[json('${selected.vCpu}')]`); - assert.equal(container.resources.memory, `${selected.memoryGiB}Gi`); - } - const memory = [gateway, data].reduce((sum, container) => sum + Number(container.resources.memory.replace('Gi', '')), 0); - assert.equal(memory, costs.compute.resources.memoryGiB); - assert.equal(app.properties.template.scale.minReplicas, costs.compute.minReplicas); - assert.equal(app.properties.template.scale.maxReplicas, costs.compute.maxReplicas); - assert.equal(gateway.image, "[parameters('gatewayImage')]"); - assert.equal(data.image, "[parameters('dabImage')]"); - assert.equal(gateway.env.find(variable => variable.name === 'SQL_APPS_PROFILE').value, 'public-demo'); - for (const key of ['sql-apps-deployment', 'sql-apps-application', 'sql-apps-profile']) { - assert.ok(JSON.stringify(template.variables.ownedTags).includes(key), `Missing runtime ownership tag ${key}`); - } - assert.equal(gateway.env.find(variable => variable.name === 'DAB_URL').value, 'http://127.0.0.1:5000'); - assert.equal( - gateway.env.find(variable => variable.name === 'PUBLIC_ORIGIN').value, - "[format('https://{0}', reference(resourceId('Microsoft.Web/staticSites', parameters('staticWebAppName')), '2023-12-01').defaultHostname)]", - ); - const connection = data.env.find(variable => variable.name === 'SQL_CONNECTION_STRING').value; - assert.match(connection, /Active Directory Managed Identity/); - assert.match(connection, /clientId/); - assert.match(connection, /TrustServerCertificate=False/); - assert.doesNotMatch(connection, /Password=/i); - assert.deepEqual(data.env.filter(variable => /URLS|PORT/.test(variable.name)), [ - { name: 'ASPNETCORE_URLS', value: 'http://127.0.0.1:5000' }, - ]); - assert.ok(gateway.probes.some(probe => probe.type === 'Readiness' && probe.httpGet.path === '/health/ready')); - for (const key of ['publicUrl', 'backendUrl', 'staticWebAppName']) { - assert.ok(template.outputs[key], `Missing runtime output ${key}`); - } - assert.doesNotMatch(JSON.stringify(template), /Simulator|Microsoft\.Graph|functionsImage/); -}); diff --git a/tests/demo-init.test.ts b/tests/demo-init.test.ts deleted file mode 100644 index 7ad66e1..0000000 --- a/tests/demo-init.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; -import { createDemoPublicationFile, initDemoPublication } from '../src/demo-init.js'; -import { demoPublicationSchema } from '../src/demo-publication.js'; -import { createJsonFile } from '../src/deployment.js'; -import { run, type Run, type RunOptions } from '../src/process.js'; - -const subscriptionId = '11111111-1111-4111-8111-111111111111'; -const tenantId = '22222222-2222-4222-8222-222222222222'; -const operatorId = '33333333-3333-4333-8333-333333333333'; -const deploymentId = 'ABCDEF12-3456-4789-ABCD-EF1234567890'; -const options = { - subscriptionId, - resourceGroup: 'rg-sqlapps-demo', - location: 'eastus', - staticWebAppLocation: 'eastus2', - selectedApplication: 'todo', - selectedArtifact: '.', - owner: ' Demo Owner ', -}; - -function jwt(claims: object): string { - const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); - return `${encode({ alg: 'none' })}.${encode(claims)}.signature`; -} - -function harness(ipResponse: Response = Response.json({ ip: '203.0.113.10' })) { - const calls: { command: string; args: readonly string[]; options: RunOptions | undefined }[] = []; - const requests: { input: string; init: RequestInit | undefined }[] = []; - const runner: Run = async (command, args, runOptions) => { - calls.push({ command, args, options: runOptions }); - if (args[0] === 'account' && args[1] === 'show') return JSON.stringify({ id: subscriptionId, tenantId }); - if (args[0] === 'account' && args[1] === 'get-access-token') { - return JSON.stringify({ accessToken: jwt({ oid: operatorId, name: 'Ada Lovelace' }) }); - } - throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); - }; - const fetcher: typeof fetch = async (input, init) => { - requests.push({ input: String(input), init }); - return ipResponse; - }; - return { runner, fetcher, calls, requests }; -} - -test('initDemoPublication derives a complete deterministic Starter publication', async () => { - const fake = harness(); - const publication = await initDemoPublication(options, fake.runner, fake.fetcher, () => deploymentId); - - assert.deepEqual(demoPublicationSchema.parse(publication), publication); - assert.equal(publication.deploymentId, deploymentId.toLowerCase()); - assert.equal(publication.profile, 'public-demo'); - assert.equal(publication.securityPosture, 'starter'); - assert.equal(publication.hosting, 'static-web-apps'); - assert.equal(publication.name, 'sqlapps'); - assert.equal(publication.subscriptionId, subscriptionId); - assert.equal(publication.tenantId, tenantId); - assert.equal(publication.applicationName, 'sqlapps-abcdef12-web'); - assert.equal(publication.staticWebAppName, 'sqlapps-abcdef12-static'); - assert.equal(publication.registryServer, 'sqlappsdemoabcdef123456.azurecr.io'); - assert.equal(publication.gatewayImage, 'sqlappsdemoabcdef123456.azurecr.io/sqlapps-todo-api:abcdef12'); - assert.equal(publication.dabImage, 'sqlappsdemoabcdef123456.azurecr.io/sqlapps-todo-dab:abcdef12'); - assert.equal(publication.selectedArtifact, resolve('.')); - assert.equal(publication.sqlAdminObjectId, operatorId); - assert.equal(publication.sqlAdminName, 'Ada Lovelace'); - assert.equal(publication.schemaRunnerIp, '203.0.113.10'); - assert.deepEqual(publication.cost, { intent: 'review-paid-costs', acknowledgeFixedCharges: true }); - assert.deepEqual(publication.sqlSku, { - name: 'GP_S_Gen5_2', tier: 'GeneralPurpose', family: 'Gen5', capacity: 2, - }); - assert.equal(publication.sqlBillingMode, 'free-paused'); - assert.equal(publication.sqlMaxSizeBytes, 32 * 1024 ** 3); - assert.equal(publication.sqlBackupRedundancy, 'Local'); - assert.deepEqual(publication.tags, { environment: 'synthetic-demo', owner: 'Demo Owner' }); -}); - -test('initializer uses only account discovery and a sensitive ARM token call', async () => { - const fake = harness(); - await initDemoPublication(options, fake.runner, fake.fetcher, () => deploymentId); - - assert.deepEqual(fake.calls.map(call => [call.command, ...call.args]), [ - ['az', 'account', 'show', '--subscription', subscriptionId, '--query', '{id:id,tenantId:tenantId}', '-o', 'json'], - ['az', 'account', 'get-access-token', '--subscription', subscriptionId, - '--resource', 'https://management.azure.com/', '-o', 'json'], - ]); - assert.equal(fake.calls[1]?.options?.sensitiveOutput, true); - assert.ok(fake.calls.every(call => !call.args.includes('ad'))); - assert.ok(fake.calls.every(call => !call.args.some(arg => ['create', 'update', 'set', 'delete'].includes(arg)))); -}); - -test('initializer requests public IPv4 without authorization and rejects bad responses explicitly', async () => { - const fake = harness(); - await initDemoPublication(options, fake.runner, fake.fetcher, () => deploymentId); - assert.equal(fake.requests[0]?.input, 'https://api.ipify.org?format=json'); - assert.equal(new Headers(fake.requests[0]?.init?.headers).has('authorization'), false); - assert.equal(new Headers(fake.requests[0]?.init?.headers).get('accept'), 'application/json'); - assert.equal(fake.requests[0]?.init?.redirect, 'error'); - assert.ok(fake.requests[0]?.init?.signal); - - for (const [response, message] of [ - [Response.json({ ip: '2001:db8::1' }), /valid public IPv4/i], - [Response.json({ ip: '10.0.0.8' }), /valid public IPv4/i], - [Response.json({ address: '203.0.113.10' }), /malformed.*IPv4/i], - [Response.json({ ip: '203.0.113.10' }, { status: 503 }), /failed.*503/i], - ] as const) { - const broken = harness(response); - await assert.rejects( - initDemoPublication(options, broken.runner, broken.fetcher, () => deploymentId), - message, - ); - } -}); - -test('initializer validates account and JWT identity evidence without Graph fallback', async () => { - const base = harness(); - const wrongAccount: Run = async (_command, args, runOptions) => { - if (args[1] === 'show') return JSON.stringify({ id: operatorId, tenantId }); - return base.runner('az', args, runOptions); - }; - await assert.rejects( - initDemoPublication(options, wrongAccount, base.fetcher, () => deploymentId), - /requested subscription/i, - ); - - for (const token of ['two.parts', jwt({ oid: operatorId }), jwt({ oid: 'not-a-uuid', preferred_username: 'ada@contoso.com' })]) { - const invalidToken: Run = async (_command, args) => args[1] === 'show' - ? JSON.stringify({ id: subscriptionId, tenantId }) - : JSON.stringify({ accessToken: token }); - await assert.rejects( - initDemoPublication(options, invalidToken, base.fetcher, () => deploymentId), - /token|claim|identity/i, - ); - } -}); - -test('demo-init CLI refuses to overwrite before Azure discovery', async t => { - const directory = resolve('.task-demo-init-test'); - const path = resolve(directory, 'existing.json'); - await mkdir(directory, { recursive: true }); - await writeFile(path, '{"sentinel":true}'); - t.after(() => rm(directory, { recursive: true, force: true })); - - await assert.rejects( - run(process.execPath, [resolve('dist/src/cli.js'), 'demo-init', path, - '--subscription', subscriptionId, '--resource-group', options.resourceGroup, - '--location', options.location, '--static-web-app-location', options.staticWebAppLocation, - '--application', options.selectedApplication, '--artifact', '.', '--owner', 'Demo Owner']), - /already exists.*never overwrites/i, - ); -}); - -test('demo-init does not overwrite a configuration created during discovery', async t => { - const directory = resolve('.task-demo-init-race-test'); - const path = resolve(directory, 'publication.json'); - const concurrent = '{"concurrent":true}'; - await mkdir(directory, { recursive: true }); - t.after(() => rm(directory, { recursive: true, force: true })); - - const fake = harness(); - let discoveryStarted!: () => void; - const started = new Promise(resolve => { discoveryStarted = resolve; }); - let finishDiscovery!: () => void; - const blocked = new Promise(resolve => { finishDiscovery = resolve; }); - const fetcher: typeof fetch = async (input, init) => { - discoveryStarted(); - await blocked; - return fake.fetcher(input, init); - }; - - const initializing = createDemoPublicationFile(path, options, fake.runner, fetcher, () => deploymentId); - const rejected = assert.rejects( - initializing, - (error: NodeJS.ErrnoException) => error.code === 'EEXIST', - ); - await started; - await writeFile(path, concurrent); - finishDiscovery(); - await rejected; - assert.equal(await readFile(path, 'utf8'), concurrent); -}); - -test('atomic publication collision preserves the destination and removes its temporary file', async t => { - const directory = resolve('.task-demo-publication-collision-test'); - const path = join(directory, 'publication.json'); - const existing = '{"existing":true}'; - await mkdir(directory, { recursive: true }); - await writeFile(path, existing); - t.after(() => rm(directory, { recursive: true, force: true })); - - await assert.rejects( - createJsonFile(path, { replacement: true }), - (error: NodeJS.ErrnoException) => error.code === 'EEXIST', - ); - - assert.equal(await readFile(path, 'utf8'), existing); - assert.deepEqual(await readdir(directory), ['publication.json']); -}); - -test('temporary name collision preserves the unowned file and does not publish', async t => { - const directory = resolve('.task-demo-temporary-collision-test'); - const path = join(directory, 'publication.json'); - const temporary = join(directory, 'existing.tmp'); - const existing = Buffer.from([0x00, 0xff, 0x10, 0x80]); - await mkdir(directory, { recursive: true }); - await writeFile(temporary, existing); - t.after(() => rm(directory, { recursive: true, force: true })); - - await assert.rejects( - createJsonFile(path, { replacement: true }, () => temporary), - (error: NodeJS.ErrnoException) => error.code === 'EEXIST', - ); - - assert.deepEqual(await readFile(temporary), existing); - await assert.rejects( - readFile(path), - (error: NodeJS.ErrnoException) => error.code === 'ENOENT', - ); -}); - -test('pre-publication write failure leaves no final file or temporary artifact', async t => { - const directory = resolve('.task-demo-publication-write-failure-test'); - const path = join(directory, `${'x'.repeat(240)}.json`); - await mkdir(directory, { recursive: true }); - t.after(() => rm(directory, { recursive: true, force: true })); - - await assert.rejects(createJsonFile(path, { publication: true })); - - assert.deepEqual(await readdir(directory), []); -}); diff --git a/tests/demo-publication.test.ts b/tests/demo-publication.test.ts deleted file mode 100644 index 1fb88e5..0000000 --- a/tests/demo-publication.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { after, before, test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFile, rm, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { buildSelectedApplication } from '../src/application-build.js'; -import { - demoPublicationApprovalId, demoPublicationSchema, demoPublicationStatePath, planDemoPublication, publishDemo, - readDemoPublicationState, validateDemoPublication, type DemoPublicationConfig, -} from '../src/demo-publication.js'; -import type { Run } from '../src/process.js'; -import { initializeWorkspace, readWorkspace, workspaceFile } from '../src/workspace.mjs'; - -let createdWorkspace = false; - -before(async () => { - if (!readWorkspace('.')) { - await initializeWorkspace('.', 59990, async () => 'free'); - createdWorkspace = true; - } -}); - -after(async () => { - if (createdWorkspace) await rm(workspaceFile('.'), { force: true }); -}); - -const base = { - version: 1 as const, - profile: 'public-demo' as const, - securityPosture: 'starter' as const, - hosting: 'static-web-apps' as const, - name: 'sqlapps', - subscriptionId: '11111111-1111-4111-8111-111111111111', - tenantId: '22222222-2222-4222-8222-222222222222', - resourceGroup: 'rg-sql-apps-demo', - location: 'eastus', - staticWebAppLocation: 'eastus2', - deploymentId: '33333333-3333-4333-8333-333333333333', - applicationName: 'sqlapps-33333333-web', - staticWebAppName: 'sqlapps-demo-static', - registryServer: 'example.azurecr.io', - gatewayImage: 'example.azurecr.io/sqlapps-demo-api:1.0.0', - dabImage: 'example.azurecr.io/sqlapps-demo-dab:1.0.0', - selectedApplication: 'todo', - selectedArtifact: '', - sqlAdminObjectId: '44444444-4444-4444-8444-444444444444', - sqlAdminName: 'Demo Administrator', - schemaRunnerIp: '203.0.113.10', - cost: { intent: 'review-paid-costs' as const, acknowledgeFixedCharges: true as const }, - sqlBillingMode: 'free-paused' as const, - sqlSku: { name: 'GP_S_Gen5_2', tier: 'GeneralPurpose', family: 'Gen5', capacity: 2 }, - sqlMaxSizeBytes: 32 * 1024 ** 3, - sqlBackupRedundancy: 'Local' as const, - tags: { owner: 'tests' }, -}; - -test('publication configuration is strict, SWA-only, ACR-bound and carries complete SQL policy', () => { - const artifact = process.cwd(); - assert.equal(demoPublicationSchema.safeParse({ ...base, selectedArtifact: artifact }).success, true); - for (const value of [ - { ...base, selectedArtifact: artifact, unknown: true }, - { ...base, selectedArtifact: artifact, hosting: 'container-apps' }, - { ...base, selectedArtifact: artifact, gatewayImage: 'other.azurecr.io/api:1' }, - { ...base, selectedArtifact: artifact, gatewayImage: 'example.azurecr.io/api:latest;bad' }, - { ...base, selectedArtifact: artifact, sqlMaxSizeBytes: 1 }, - { ...base, selectedArtifact: artifact, cost: { intent: 'zero-azure-spend', acknowledgeFixedCharges: false } }, - { ...base, selectedArtifact: artifact, cost: { intent: 'review-paid-costs', acknowledgeFixedCharges: false } }, - ]) assert.equal(demoPublicationSchema.safeParse(value).success, false); -}); - -test('publication accepts only explicit Starter posture', () => { - const artifact = process.cwd(); - assert.equal(demoPublicationSchema.safeParse({ - ...base, selectedArtifact: artifact, securityPosture: 'starter', - }).success, true); - for (const securityPosture of [undefined, 'hardened', 'automatic']) { - assert.equal(demoPublicationSchema.safeParse({ - ...base, selectedArtifact: artifact, securityPosture, - }).success, false); - } -}); - -test('review is read-only and approval binds configuration, artifact manifest and pending stages', async t => { - const built = await buildSelectedApplication('.', 'todo', 'public-demo'); - t.after(() => rm(built.directory, { recursive: true, force: true })); - const config = demoPublicationSchema.parse({ ...base, selectedArtifact: built.directory }); - await validateDemoPublication(config); - const first = planDemoPublication(config); - assert.deepEqual(first.pending, ['infrastructure', 'artifacts', 'schema', 'backend', 'static', 'ready']); - assert.match(first.warning, /PUBLIC SYNTHETIC DEMO ONLY/); - assert.match(first.review.join('\n'), /Static Web Apps eastus2/); - assert.ok(first.review.includes( - 'Security posture: Starter; ACR image pulls use an Azure-managed Container App secret until separately approved hardening', - )); - assert.ok(first.excluded.includes( - 'No real/customer data, production SLA, custom domain, paid SQL fallback, production hardening, or default-hosting change', - )); - assert.match(first.review.join('\n'), /temporarily allow only 203\.0\.113\.10/); - assert.notEqual(first.approval, planDemoPublication({ ...config, staticWebAppName: 'sqlapps-demo-other' }).approval); - assert.notEqual( - demoPublicationApprovalId(config), - demoPublicationApprovalId({ ...config, securityPosture: 'hardened' }), - ); - const manifest = join(built.directory, 'application-build.json'); - const original = await readFile(manifest, 'utf8'); - await writeFile(manifest, `${original}\n`); - assert.notEqual(first.approval, planDemoPublication(config).approval); - await writeFile(manifest, original); -}); - -function harness(config: DemoPublicationConfig, failure?: 'image' | 'schema' | 'backend' | 'static') { - const calls: string[][] = []; - const requests: string[] = []; - const swaEnvironments: NodeJS.ProcessEnv[] = []; - let sessions = 0; - const deletedSessions = new Set(); - let itemOwner: string | undefined; - const digest = `sha256:${'a'.repeat(64)}`; - const infrastructure = { - environmentId: `/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}/providers/Microsoft.App/managedEnvironments/env`, - environmentName: 'env', applicationName: config.applicationName, - runtimeIdentityResourceId: `/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/runtime`, - runtimeIdentityName: 'runtime', runtimeIdentityClientId: '55555555-5555-4555-8555-555555555555', - runtimeIdentityPrincipalId: '66666666-6666-4666-8666-666666666666', runtimeIdentityTenantId: config.tenantId, - registryId: `/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}/providers/Microsoft.ContainerRegistry/registries/example`, - registryName: 'example', registryServer: config.registryServer, - networkId: `/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}/providers/Microsoft.Network/virtualNetworks/network`, - subnetId: `/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}/providers/Microsoft.Network/virtualNetworks/network/subnets/containers`, - sqlServerId: `/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}/providers/Microsoft.Sql/servers/server`, - sqlServerName: 'server', sqlHost: 'server.database.windows.net', - databaseId: `/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}/providers/Microsoft.Sql/servers/server/databases/app`, - databaseName: 'app', ownershipTags: { owner: 'tests' }, sqlBillingMode: config.sqlBillingMode, - logsWorkspaceId: `/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}/providers/Microsoft.OperationalInsights/workspaces/logs`, - logsWorkspaceName: 'logs', - }; - const runtime = { - applicationId: `/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}/providers/Microsoft.App/containerApps/${config.applicationName}`, - publicUrl: 'https://demo.example', backendUrl: 'https://backend.example', - staticWebAppName: config.staticWebAppName, - }; - const envelope = (value: Record) => - JSON.stringify(Object.fromEntries(Object.entries(value).map(([key, item]) => [key, { value: item }]))); - const runner: Run = async (command, args, options) => { - calls.push([command, ...args]); - if (command === 'npx' && args.includes('deploy')) swaEnvironments.push(options?.env ?? {}); - if (args[0] === 'account' && args[1] === 'list') return '[]'; - if (args[0] === 'account' && args[1] === 'get-access-token') { - if (args.includes('https://management.azure.com/')) { - return JSON.stringify({ accessToken: 'management-token', tenant: config.tenantId, - expires_on: String(Math.floor(Date.now() / 1000) + 3600) }); - } - return JSON.stringify({ accessToken: 'sql-sensitive-token' }); - } - if (args[0] === 'acr' && args[1] === 'repository') return digest; - if (failure === 'image' && args[0] === 'acr' && args[1] === 'build') throw new Error('image rejected'); - if (args[0] === 'staticwebapp' && args[1] === 'secrets') return 'static-deployment-token-for-tests'; - if (args[0] === 'deployment' && args.includes('infra/demo.bicep')) return envelope(infrastructure); - if (args[0] === 'deployment' && args.includes('infra/demo-runtime.bicep')) return envelope(runtime); - if (failure === 'schema' && args.some(arg => arg === '/Action:Publish')) throw new Error('schema rejected'); - return ''; - }; - const fetcher: typeof fetch = async (input, init) => { - const url = new URL(String(input)); - requests.push(url.href); - if (url.origin === 'https://management.azure.com') { - if (url.pathname.endsWith('/locations')) return Response.json({ value: [ - { name: config.location, displayName: 'East US' }, - { name: config.staticWebAppLocation, displayName: 'East US 2' }, - ] }); - if (url.pathname.endsWith('/permissions')) return Response.json({ value: [{ actions: ['*'], notActions: [] }] }); - const provider = /\/providers\/(Microsoft\.[^/]+)$/.exec(url.pathname)?.[1]; - if (provider) { - const types: Record = { - 'Microsoft.App': ['managedEnvironments', 'containerApps'], 'Microsoft.Sql': ['servers'], - 'Microsoft.Network': ['virtualNetworks'], 'Microsoft.ContainerRegistry': ['registries'], - 'Microsoft.ManagedIdentity': ['userAssignedIdentities'], 'Microsoft.Authorization': ['roleAssignments'], - 'Microsoft.Web': ['staticSites', 'staticSites/linkedBackends'], - }; - return Response.json({ namespace: provider, registrationState: 'Registered', - resourceTypes: types[provider]!.map(resourceType => ({ - resourceType, locations: [provider === 'Microsoft.Web' ? 'East US 2' : 'East US'], - })) }); - } - if (url.pathname.toLowerCase().endsWith(`/resourcegroups/${config.resourceGroup}`.toLowerCase())) { - return Response.json({ id: `/subscriptions/${config.subscriptionId}/resourcegroups/${config.resourceGroup}`, location: config.location }); - } - return Response.json({ subscriptionId: config.subscriptionId, tenantId: config.tenantId, state: 'Enabled' }); - } - if (url.origin === runtime.backendUrl) { - return Response.json({ error: 'denied' }, { status: 403 }); - } - if (url.pathname === '/api/health/ready') { - return new Response('', { status: failure === 'backend' ? 400 : 200 }); - } - if (url.pathname === '/app.js') { - return new Response(failure === 'static' ? 'upload-failed' : - await readFile(join(config.selectedArtifact, 'static', 'app.js')), { status: failure === 'static' ? 400 : 200 }); - } - if (url.pathname === '/') return new Response('', { status: 200 }); - const requestHeaders = init?.headers as Record | undefined; - if (requestHeaders?.origin === 'https://foreign.invalid' || - requestHeaders?.['x-ms-client-principal'] !== undefined || - requestHeaders?.['x-ms-api-role'] !== undefined) { - return Response.json({ error: 'forbidden' }, { status: 403 }); - } - if (url.pathname === '/api/session' && init?.method === 'POST') { - sessions++; - return Response.json({ expiresAt: new Date(Date.now() + 60_000).toISOString() }, - { status: 201, headers: { 'set-cookie': `__Host-sql_apps_session=session-${sessions}; Path=/; Secure; HttpOnly; SameSite=Strict` } }); - } - if (url.pathname === '/api/session' && init?.method === 'DELETE') { - deletedSessions.add(String(requestHeaders?.cookie)); - return new Response(null, { status: 204 }); - } - if (url.pathname === '/api/session' && deletedSessions.has(String(requestHeaders?.cookie))) { - return Response.json({ error: 'unauthorized' }, { status: 401 }); - } - if (url.pathname === '/api/todos' && init?.method === 'POST') { - itemOwner = requestHeaders?.cookie; - return Response.json({ id: '77777777-7777-4777-8777-777777777777', title: 'test', completed: false }, { status: 201 }); - } - if (url.pathname === '/api/todos') { - return Response.json({ items: requestHeaders?.cookie === itemOwner ? - [{ id: '77777777-7777-4777-8777-777777777777' }] : [] }); - } - if (init?.method === 'PATCH') { - if (requestHeaders?.cookie !== itemOwner) return Response.json({ error: 'not found' }, { status: 404 }); - return Response.json({ id: '77777777-7777-4777-8777-777777777777', title: 'test', completed: true }); - } - if (init?.method === 'DELETE') return new Response(null, { status: 204 }); - throw new Error(`Unexpected fetch ${url} ${init?.method ?? 'GET'}`); - }; - return { runner, fetcher, calls, requests, swaEnvironments }; -} - -async function cleanup(config: DemoPublicationConfig) { - const path = demoPublicationStatePath(config); - for (const suffix of ['', '.infrastructure.parameters.json', '.runtime.parameters.json', '.schema', '.lock']) { - await rm(`${path}${suffix}`, { recursive: true, force: true }); - } -} - -test('publication is ordered, resumable, atomic, token-free and uses the local SWA CLI', async t => { - const built = await buildSelectedApplication('.', 'todo', 'public-demo'); - t.after(() => rm(built.directory, { recursive: true, force: true })); - const config = demoPublicationSchema.parse({ ...base, selectedArtifact: built.directory, - deploymentId: '88888888-8888-4888-8888-888888888888' }); - t.after(() => cleanup(config)); - const fake = harness(config); - const approval = planDemoPublication(config).approval; - const state = await publishDemo(config, fake.runner, fake.fetcher, approval); - assert.equal(state.stage, 'ready'); - assert.equal(state.securityPosture, 'starter'); - assert.equal(state.runtime?.staticWebAppName, config.staticWebAppName); - assert.equal(state.registryAuthentication, 'credential'); - assert.equal(state.acrAdminEnabled, true); - assert.ok(fake.requests.some(request => new URL(request).pathname === '/api/todos')); - assert.ok(fake.requests.some(request => new URL(request).pathname === '/api/health/ready')); - assert.deepEqual(planDemoPublication(config, state).pending, []); - assert.equal(fake.requests.some(request => request.includes('/providers/Microsoft.Authorization?')), false); - const image = fake.calls.findIndex(call => call.includes('acr') && call.includes('build')); - const infrastructure = fake.calls.findIndex(call => call.includes('infra/demo.bicep')); - const schema = fake.calls.findIndex(call => call.includes('/Action:Publish')); - const backend = fake.calls.findIndex(call => call.includes('infra/demo-runtime.bicep')); - const staticDeploy = fake.calls.findIndex(call => call.includes('swa') && call.includes('deploy')); - assert.ok(infrastructure < image && image < schema && schema < backend && backend < staticDeploy); - const firewallCreate = fake.calls.findIndex(call => call.includes('firewall-rule') && call.includes('create')); - const firewallDelete = fake.calls.findIndex(call => call.includes('firewall-rule') && call.includes('delete')); - assert.ok(firewallCreate < schema && schema < firewallDelete && firewallDelete < backend); - assert.ok(fake.calls[firewallCreate]!.includes(config.schemaRunnerIp)); - const swa = fake.calls[staticDeploy]!; - assert.deepEqual(swa.slice(swa.indexOf('swa')), ['swa', 'deploy', join(built.directory, 'static'), - '--env', 'production', '--no-use-keychain']); - assert.equal(fake.calls.some(call => call.includes('static-deployment-token-for-tests')), false); - assert.deepEqual(fake.swaEnvironments, [{ - SWA_CLI_DEPLOYMENT_TOKEN: 'static-deployment-token-for-tests', - AZURE_STATIC_WEB_APPS_API_TOKEN: '', - AZURE_CLIENT_ID: '', - AZURE_CLIENT_SECRET: '', - AZURE_TENANT_ID: '', - AZURE_SUBSCRIPTION_ID: '', - }]); - const saved = await readFile(demoPublicationStatePath(config), 'utf8'); - assert.doesNotMatch(saved, /token|password|static-deployment-token-for-tests/i); - assert.equal((await readDemoPublicationState(config)).workspaceHome, process.cwd()); - const infrastructureParameters = JSON.parse( - await readFile(`${demoPublicationStatePath(config)}.infrastructure.parameters.json`, 'utf8'), - ); - assert.equal(infrastructureParameters.parameters.registryName.value, 'example'); - const runtimeParameters = JSON.parse(await readFile(`${demoPublicationStatePath(config)}.runtime.parameters.json`, 'utf8')); - assert.deepEqual(Object.fromEntries(Object.entries(runtimeParameters.parameters) - .filter(([name]) => ['applicationName', 'environmentName', 'runtimeIdentityName', 'registryName', - 'sqlServerName', 'databaseName', 'staticWebAppName'].includes(name)) - .map(([name, parameter]) => [name, (parameter as { value: unknown }).value])), { - applicationName: config.applicationName, - environmentName: 'env', - runtimeIdentityName: 'runtime', - registryName: 'example', - sqlServerName: 'server', - databaseName: 'app', - staticWebAppName: config.staticWebAppName, - }); - assert.equal(runtimeParameters.parameters.staticWebAppLocation.value, config.staticWebAppLocation); - assert.equal(runtimeParameters.parameters.securityPosture.value, 'starter'); - assert.equal(JSON.stringify(runtimeParameters).includes('password'), false); - assert.equal(JSON.stringify(runtimeParameters).includes('credential'), false); - const before = fake.calls.length; - await publishDemo(config, fake.runner, fake.fetcher, planDemoPublication(config, state).approval); - assert.equal(fake.calls.slice(before).some(call => call.includes('deployment') || call.includes('build')), false); -}); - -test('image failure retains infrastructure and resumes without redeploying the foundation', async t => { - const built = await buildSelectedApplication('.', 'todo', 'public-demo'); - t.after(() => rm(built.directory, { recursive: true, force: true })); - const config = demoPublicationSchema.parse({ ...base, selectedArtifact: built.directory, - deploymentId: '14141414-1414-4141-8141-141414141414' }); - t.after(() => cleanup(config)); - const failed = harness(config, 'image'); - await assert.rejects( - publishDemo(config, failed.runner, failed.fetcher, planDemoPublication(config).approval), - /image rejected/, - ); - const infrastructureState = await readDemoPublicationState(config); - assert.equal(infrastructureState.stage, 'infrastructure'); - assert.ok(infrastructureState.infrastructure); - assert.equal(infrastructureState.pinnedImages, null); - - const resumed = harness(config); - const state = await publishDemo( - config, resumed.runner, resumed.fetcher, planDemoPublication(config, infrastructureState).approval, - ); - assert.equal(state.stage, 'ready'); - assert.equal(resumed.calls.some(call => call.includes('infra/demo.bicep')), false); - assert.equal(resumed.calls.some(call => call.includes('acr') && call.includes('build')), true); -}); - -test('schema, backend, and static verification failures block all later publication stages', async t => { - const built = await buildSelectedApplication('.', 'todo', 'public-demo'); - t.after(() => rm(built.directory, { recursive: true, force: true })); - for (const [failure, deploymentId, lastStage] of [ - ['schema', '99999999-9999-4999-8999-999999999999', 'artifacts'], - ['backend', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'schema'], - ['static', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'backend'], - ] as const) { - const config = demoPublicationSchema.parse({ ...base, selectedArtifact: built.directory, deploymentId }); - t.after(() => cleanup(config)); - const fake = harness(config, failure); - await assert.rejects(publishDemo(config, fake.runner, fake.fetcher, planDemoPublication(config).approval), - failure === 'schema' ? /schema rejected/ : - failure === 'backend' ? /Backend readiness/ : /Static content/); - assert.equal((await readDemoPublicationState(config)).stage, lastStage); - assert.equal(fake.calls.some(call => call.includes('swa') && call.includes('deploy')), failure === 'static'); - if (failure === 'schema') assert.equal(fake.calls.some(call => call.includes('infra/demo-runtime.bicep')), false); - } -}); diff --git a/tests/deployment.test.ts b/tests/deployment.test.ts index a235daa..5710fc4 100644 --- a/tests/deployment.test.ts +++ b/tests/deployment.test.ts @@ -6,39 +6,44 @@ import { dirname, join } from 'node:path'; import fs from 'node:fs/promises'; import { syncBuiltinESMExports } from 'node:module'; import { deploymentSchema, environmentKey, portFromEnvironment } from '../src/config.js'; -import { armParameters, configureRedirect, deploy, functionRoleId, preflight, saveState, statePath, withDeploymentLock, type DeploymentState, type DeploymentOutputs } from '../src/deployment.js'; +import { + armParameters, configureRedirect, deploy, deploymentConfigHash, expectedRbacAssignments, grantsHandoffPath, preflight, + rbacParameters, saveState, statePath, verifyRbacAssignments, withDeploymentLock, + type DeploymentState, type DeploymentOutputs, +} from '../src/deployment.js'; +import { roleBasedSchemaFingerprint } from '../src/role-based-profile.js'; import type { Run } from '../src/process.js'; -const config = deploymentSchema.parse(JSON.parse(await readFile('sql-apps.example.json', 'utf8'))); +const example = JSON.parse(await readFile('sql-apps.example.json', 'utf8')); +const config = deploymentSchema.parse({ ...example, imageSource: await roleBasedSchemaFingerprint(process.cwd()) }); const outputs: DeploymentOutputs = { sqlServer: 'example.database.windows.net', databaseName: 'app', dabPrincipalId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', gatewayPrincipalId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', - gatewayName: 'gateway', gatewayUrl: 'https://example.azurecontainerapps.io', - functionsName: 'functions', networkId: '/network', storageAccount: 'storage', vaultName: 'vault', + gatewayName: 'gateway', gatewayUrl: 'https://example.azurecontainerapps.io', networkId: '/network', sqlBackupRetentionDays: 7, sqlBackupRedundancy: 'Geo' as const, sqlAuditWorkspaceName: 'logs', }; -function fakeRunner(failSchema = false): { run: Run; calls: string[][] } { +function fakeRunner(failSchema = false): { run: Run; calls: string[][]; environments: (NodeJS.ProcessEnv | undefined)[] } { const calls: string[][] = []; - const run: Run = async (command, args) => { + const environments: (NodeJS.ProcessEnv | undefined)[] = []; + const run: Run = async (command, args, options) => { calls.push([command, ...args]); - if (args[0] === 'account' && args[1] === 'get-access-token') return '{"accessToken":"test-only-sql-token"}'; + environments.push(options?.env); if (args[0] === 'account') return JSON.stringify({ id: config.subscriptionId, tenantId: config.tenantId, environmentName: 'AzureCloud' }); if (args[0] === 'ad' && args[1] === 'app') return JSON.stringify({ id: config.apiClientId, signInAudience: 'AzureADMyOrg', api: { requestedAccessTokenVersion: 2, oauth2PermissionScopes: [{ value: 'access_as_user', isEnabled: true }] }, - appRoles: [{ id: '15d91b1c-83c7-4cb6-b349-d7b8fd4b1425', value: 'Function.Invoke', isEnabled: true }], + appRoles: [{ id: '15d91b1c-83c7-4cb6-b349-d7b8fd4b1425', value: config.requiredRole, isEnabled: true, allowedMemberTypes: ['User'] }], spa: { redirectUris: ['http://localhost:8080'] }, }); - if (args[0] === 'ad' && args[1] === 'sp') return config.apiClientId; if (args[0] === 'provider') return 'Registered'; if (args[0] === 'acr' && args[1] === 'repository') return `sha256:${'a'.repeat(64)}`; - if (args[0] === 'rest' && args.includes('GET')) return '{"value":[]}'; + if (args[0] === 'role' && args[1] === 'assignment') return JSON.stringify(expectedRbacAssignments(config, outputs)); if (args[0] === 'deployment') return JSON.stringify(Object.fromEntries(Object.entries(outputs).map(([key, value]) => [key, { value }]))); if (failSchema && args.includes('/Action:Publish')) throw new Error('SQL network unavailable'); return ''; }; - return { run, calls }; + return { run, calls, environments }; } test('configuration rejects unknown platform settings and floating images', () => { assert.throws(() => deploymentSchema.parse({ ...config, fabric: {} })); @@ -46,6 +51,8 @@ test('configuration rejects unknown platform settings and floating images', () = assert.throws(() => deploymentSchema.parse({ ...config, resourceGroup: 'rg;evil' })); assert.throws(() => deploymentSchema.parse({ ...config, gatewayImage: 'unrelated.azurecr.io/image:v1' })); assert.throws(() => deploymentSchema.parse({ ...config, gatewayImage: `${config.registryServer}/image;evil:v1` })); + assert.throws(() => deploymentSchema.parse({ ...config, profile: 'foundation' })); + assert.throws(() => deploymentSchema.parse({ ...config, functionsImage: `${config.registryServer}/fn:v1` })); }); test('runtime port validation rejects empty, invalid, and out-of-range values', () => { assert.equal(portFromEnvironment(undefined), 8080); @@ -54,11 +61,50 @@ test('runtime port validation rejects empty, invalid, and out-of-range values', }); test('ARM parameters exclude local routing settings and identify separate environments', () => { const parameters = armParameters(config, false).parameters; - assert.equal('subscriptionId' in parameters, false); - assert.equal('resourceGroup' in parameters, false); + for (const key of ['subscriptionId', 'resourceGroup', 'profile', 'imageSource', 'functionsImage']) { + assert.equal(key in parameters, false, key); + } assert.deepEqual(parameters.deployGateway, { value: false }); assert.notEqual(environmentKey(config), environmentKey({ ...config, environment: 'prod' })); }); +test('RBAC handoff uses saved principals and exposes only template inputs', () => { + assert.deepEqual(rbacParameters(config, outputs).parameters, { + name: { value: config.name }, environment: { value: config.environment }, + registryServer: { value: config.registryServer }, + gatewayPrincipalId: { value: outputs.gatewayPrincipalId }, dabPrincipalId: { value: outputs.dabPrincipalId }, + }); +}); +test('expected RBAC assignments are exactly the two ACR pulls', () => { + const assignments = expectedRbacAssignments(config, outputs); + assert.deepEqual(assignments.map(item => item.label), ['ACR pull for gateway', 'ACR pull for data']); + assert.ok(assignments.every(item => item.scope === + `/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}/providers/Microsoft.ContainerRegistry/registries/example`)); +}); +test('runtime RBAC verification fails closed and names every missing assignment', async () => { + const calls: string[][] = []; + const run: Run = async (command, args) => { + calls.push([command, ...args]); + return '[]'; + }; + await assert.rejects( + verifyRbacAssignments(config, outputs, run), + error => error instanceof Error && /RBAC handoff is incomplete/.test(error.message) && + /ACR pull for gateway/.test(error.message) && /ACR pull for data/.test(error.message) && + /rbac-deploy/.test(error.message), + ); + assert.ok(calls.length > 0); + assert.ok(calls.every(call => call[1] === 'role' && call[2] === 'assignment' && call.includes('--include-inherited'))); +}); +test('runtime RBAC verification accepts only exact live principal, role and scope matches', async () => { + const expected = expectedRbacAssignments(config, outputs); + await verifyRbacAssignments(config, outputs, async () => JSON.stringify(expected)); + await assert.rejects( + verifyRbacAssignments(config, outputs, async () => JSON.stringify(expected.map(item => ({ + ...item, scope: `/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}`, + })))), + /RBAC handoff is incomplete/, + ); +}); test('missing deployment images fail preflight before infrastructure side effects', async () => { const { run, calls } = fakeRunner(); const missing: Run = async (command, args) => { @@ -68,12 +114,13 @@ test('missing deployment images fail preflight before infrastructure side effect await assert.rejects(preflight(config, missing), /Image not found/); assert.equal(calls.some(call => call.includes('deployment')), false); }); -test('static build preflight does not require its not-yet-built output', async () => { +test('preflight without image checks skips registry lookups and checks only role-based providers', async () => { const { run, calls } = fakeRunner(); await preflight(config, run, false); assert.equal(calls.some(call => call.includes('repository')), false); - assert.equal(calls.some(call => call.includes('Registered')), false); - assert.equal(calls.filter(call => call.includes('provider')).length, 10); + const providers = calls.filter(call => call[1] === 'provider').map(call => call[call.indexOf('--namespace') + 1]); + assert.deepEqual(providers, ['Microsoft.App', 'Microsoft.Sql', 'Microsoft.Network', 'Microsoft.ManagedIdentity', + 'Microsoft.OperationalInsights', 'Microsoft.Authorization']); }); test('redirect registration preserves existing URIs and adds only the dedicated HTTPS callback', async t => { const directory = await mkdtemp(join(tmpdir(), 'sql-apps-redirect-')); @@ -114,7 +161,7 @@ async function cleanupArtifacts() { } test('deployment orders infrastructure, schema, service permission, runtime and readiness', async t => { t.after(cleanupArtifacts); - const { run, calls } = fakeRunner(); + const { run, calls, environments } = fakeRunner(); const stages: string[] = []; const state = await deploy(config, run, async () => new Response('{}'), async state => { stages.push(state.stage); }); assert.equal(state.stage, 'ready'); @@ -124,7 +171,13 @@ test('deployment orders infrastructure, schema, service permission, runtime and const runtime = calls.findIndex(call => call.includes(`${config.name}-${config.environment}-runtime`)); assert.ok(infra < sql && sql < runtime); assert.ok(calls[sql]?.includes('/p:BlockOnPossibleDataLoss=True')); - assert.ok(calls[sql]?.includes('/AccessToken:test-only-sql-token')); + // A token argument is readable by other processes on the deployment host; SqlPackage must sign in itself. + assert.equal(calls.some(call => call.includes('get-access-token')), false); + assert.equal(calls[sql]?.some(arg => /^\/(AccessToken|at):/i.test(arg)), false); + assert.ok(calls[sql]?.some(arg => arg.startsWith('/TargetConnectionString:') && + arg.includes('Authentication=Active Directory Default;'))); + assert.equal(environments[sql]?.AZURE_TOKEN_CREDENTIALS, 'AzureCliCredential'); + assert.equal(environments[sql]?.AZURE_TENANT_ID, config.tenantId); }); test('failed schema publish does not start the data/gateway deployment or claim success', async t => { t.after(cleanupArtifacts); @@ -134,33 +187,45 @@ test('failed schema publish does not start the data/gateway deployment or claim assert.deepEqual(stages, ['infrastructure']); assert.equal(calls.some(call => call.includes(`${config.name}-${config.environment}-runtime`)), false); }); -test('existing function role on a later Graph page is not assigned again', async t => { +test('deploy writes the grants handoff and passes the same grants to SqlPackage', async t => { t.after(cleanupArtifacts); const { run, calls } = fakeRunner(); - let pages = 0; - const paginated: Run = async (command, args) => { - const result = await run(command, args); - if (args[0] !== 'rest' || !args.includes('GET')) return result; - pages++; - return JSON.stringify(pages === 1 - ? { value: [], '@odata.nextLink': `https://graph.microsoft.com/v1.0/servicePrincipals/${outputs.gatewayPrincipalId}/appRoleAssignments?$skiptoken=next` } - : { value: [{ appRoleId: functionRoleId, resourceId: config.apiClientId }] }); - }; - await deploy(config, paginated, async () => new Response('{}'), async () => {}); - assert.equal(pages, 2); - assert.equal(calls.some(call => call.includes('POST')), false); + await deploy(config, run, async () => new Response('{}'), async () => {}); + const handoff = await readFile(grantsHandoffPath(), 'utf8'); + assert.match(handoff, /GRANT EXECUTE ON (OBJECT::)?\[?dbo\]?\.\[?AppReady\]? TO \[sql_apps_dab\]/); + const argument = calls.find(call => call.includes('/Action:Publish'))?.find(arg => arg.startsWith('/v:RoleBasedProcedureGrants=')); + assert.ok(argument); + assert.ok(handoff.includes(argument.slice('/v:RoleBasedProcedureGrants='.length))); }); -test('Graph pagination never forwards credentials to an unrelated URL', async t => { +test('deploy refuses images built from different SQL/DAB source before any Azure change', async t => { t.after(cleanupArtifacts); const { run, calls } = fakeRunner(); - const malicious: Run = async (command, args) => { - const result = await run(command, args); - return args[0] === 'rest' && args.includes('GET') - ? JSON.stringify({ value: [], '@odata.nextLink': 'https://unrelated.example/next' }) - : result; - }; - await assert.rejects(deploy(config, malicious, async () => new Response('{}'), async () => {}), /continuation URL/); - assert.equal(calls.some(call => call.includes('https://unrelated.example/next')), false); + await assert.rejects(deploy({ ...config, imageSource: 'f'.repeat(64) }, run, async () => new Response('{}'), async () => {}), + /run artifacts, then deploy/); + await assert.rejects(deploy({ ...config, imageSource: undefined }, run, async () => new Response('{}'), async () => {}), + /run artifacts, then deploy/); + assert.equal(calls.some(call => call[1] === 'deployment' || call.includes('/Action:Publish')), false); +}); +test('a ready deployment resumes when unchanged and rolls every stage forward when the source hash changes', async t => { + t.after(async () => { + await cleanupArtifacts(); + await unlink(statePath(config)).catch(() => {}); + }); + const home = await fs.realpath(process.cwd()); + const ready = async (configHash: string) => saveState(statePath(config), + { environmentKey: environmentKey(config), configHash, stage: 'ready', outputs, applicationHome: home }); + await mkdir(dirname(statePath(config)), { recursive: true }); + await ready(await deploymentConfigHash(config)); + const unchanged = fakeRunner(); + await deploy(config, unchanged.run, async () => new Response('{}'), async () => {}); + assert.equal(unchanged.calls.some(call => call[1] === 'deployment' || call.includes('/Action:Publish')), false); + await ready('previous-source-hash'); + const changed = fakeRunner(); + const stages: string[] = []; + await deploy(config, changed.run, async () => new Response('{}'), async state => { stages.push(state.stage); }); + assert.deepEqual(stages, ['infrastructure', 'schema', 'runtime', 'ready']); + assert.ok(changed.calls.some(call => call.includes('/Action:Publish'))); + assert.ok(changed.calls.some(call => call.includes(`${config.name}-${config.environment}-runtime`))); }); test('atomic deployment state and local concurrency lock', async () => { const dir = await mkdtemp(join(tmpdir(), 'sql-apps-')); diff --git a/tests/functions-packaging.test.ts b/tests/functions-packaging.test.ts deleted file mode 100644 index 6fe5754..0000000 --- a/tests/functions-packaging.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { after, before, test, type TestContext } from 'node:test'; -import assert from 'node:assert/strict'; -import { cp, mkdir, mkdtemp, readFile, readdir, rm, symlink } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; -import { createRequire } from 'node:module'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import ts from 'typescript'; -import { z } from 'zod'; - -const execute = promisify(execFile); -const require = createRequire(import.meta.url); -interface ImageFixture { directory: string; local: boolean; entry?: string } -const packagedContext = resolve('dist', 'tooling-package'); - -before(async () => { - await execute(process.execPath, [resolve('scripts', 'build-tooling-package.mjs'), packagedContext], - { windowsHide: true, maxBuffer: 8 * 1024 * 1024 }); -}); - -after(() => rm(packagedContext, { recursive: true, force: true })); - -const buildCommands = new Map Promise>([ - ['RUN npx tsc --project tsconfig.runtime.json && npm prune --omit=dev', builder => - isolated(builder, [require.resolve('typescript/lib/tsc.js'), '--project', 'tsconfig.runtime.json'])], - ['RUN npm run build && npm prune --omit=dev', async builder => { - const script = z.object({ scripts: z.object({ build: z.string().regex(/^node scripts\/[\w.-]+\.mjs$/) }) }) - .parse(JSON.parse(await readFile(join(builder, 'package.json'), 'utf8'))).scripts.build; - await isolated(builder, [contained(builder, script.slice('node '.length))]); - }], -]); - -async function isolated(builder: string, args: string[]): Promise { - try { - await execute(process.execPath, args, { cwd: builder, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }); - } catch (cause) { - const stdout = cause instanceof Error && 'stdout' in cause && typeof cause.stdout === 'string' ? cause.stdout : ''; - const stderr = cause instanceof Error && 'stderr' in cause && typeof cause.stderr === 'string' ? cause.stderr : ''; - throw new Error(`Isolated image build failed:\n${stdout}${stderr}`, { cause }); - } -} - -function contained(root: string, path: string): string { - const candidate = resolve(root, ...path.split('/')); - const location = relative(root, candidate); - assert.ok(!isAbsolute(location) && location !== '..' && !location.startsWith(`..${sep}`)); - return candidate; -} - -async function dependencyLink(destination: string): Promise { - await symlink(resolve('node_modules'), destination, process.platform === 'win32' ? 'junction' : 'dir'); -} - -async function materializeFunctionsImage( - t: TestContext, recipe: string, transform: (text: string) => string = text => text, - context = process.cwd(), -): Promise { - const local = basename(recipe) === 'local.Dockerfile'; - const root = await mkdtemp(join(tmpdir(), 'sql-apps-functions-package-')); - t.after(() => rm(root, { recursive: true, force: true })); - const builder = join(root, 'builder'); - const image = join(root, 'image'); - await Promise.all([mkdir(builder), mkdir(image)]); - await dependencyLink(join(builder, 'node_modules')); - let stage = 0; - let compiled = false; - let entry: string | undefined; - const instructions = transform(await readFile(recipe, 'utf8')).replace(/\\\r?\n\s*/g, ' ').split(/\r?\n/); - for (const instruction of instructions) { - const line = instruction.trim(); - if (/^FROM\s/.test(line)) { - stage++; - assert.ok(stage <= 2, 'Update this bounded fixture explicitly for a new recipe stage'); - } else if (/^RUN\s/.test(line)) { - assert.equal(stage, 1); - if (line === 'RUN npm ci' || line === 'RUN npm install --omit=dev --ignore-scripts') continue; - const action = buildCommands.get(line); - assert.ok(action, `Update this bounded fixture explicitly for the build command: ${line}`); - await action(builder); - compiled = true; - } else if (/^CMD\s/.test(line)) { - const command = z.array(z.string()).parse(JSON.parse(line.slice(4).trim())); - assert.deepEqual(command.slice(0, 1), ['node'], 'Only a node entry point is replayed'); - assert.equal(command.length, 2); - entry = command[1]; - } else if (/^COPY\s/.test(line)) { - const args = line.slice(5).trim().split(/\s+/); - const fromBuild = args[0] === '--from=build'; - if (fromBuild) args.shift(); - if (args[0]?.startsWith('--chown=')) args.shift(); - assert.ok(args.every(arg => !arg.startsWith('--')), 'Unsupported COPY option'); - const destination = args.pop(); - assert.ok(destination && args.length); - const destinationRoot = stage === 1 ? builder : image; - for (const source of args) { - const input = fromBuild ? source.replace(/^\/app\//, '') : source; - const sourceRoot = fromBuild ? builder : context; - if (fromBuild && input === 'node_modules') { - await dependencyLink(contained(destinationRoot, destination)); - continue; - } - const name = basename(input); - const pattern = new RegExp(`^${name.split('*') - .map(part => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`); - const directory = contained(sourceRoot, dirname(input)); - const names = name.includes('*') ? (await readdir(directory)).filter(value => pattern.test(value)) : [name]; - assert.ok(names.length, `COPY matched no files: ${source}`); - for (const entry of names) { - const target = contained(destinationRoot, - destination.endsWith('/') || args.length > 1 || name.includes('*') - ? `${destination}/${entry}` : destination); - await mkdir(dirname(target), { recursive: true }); - await cp(join(directory, entry), target, { recursive: true }); - } - } - } - } - assert.equal(stage, local ? 1 : 2); - assert.equal(compiled, !local); - return { directory: local ? builder : image, local, ...(entry ? { entry } : {}) }; -} - -async function assertImageModuleClosure(image: ImageFixture): Promise { - const manifest = z.object({ main: z.string().optional() }) - .parse(JSON.parse(await readFile(join(image.directory, 'package.json'), 'utf8'))); - const start = image.entry ?? manifest.main; - assert.ok(start, 'The image must declare an entry point through CMD or package.json main'); - const pending = [contained(image.directory, start)]; - if (image.local) { - const collect = async (directory: string): Promise => { - for (const entry of await readdir(directory, { withFileTypes: true })) { - const path = join(directory, entry.name); - if (entry.isDirectory()) await collect(path); - else if (/\.(?:js|mjs)$/.test(entry.name)) pending.push(path); - } - }; - await collect(join(image.directory, 'dist', 'src')); - } - const visited = new Set(); - while (pending.length) { - const file = pending.pop(); - assert.ok(file); - if (visited.has(file)) continue; - visited.add(file); - const text = await readFile(file, 'utf8'); - for (const dependency of ts.preProcessFile(text, true, true).importedFiles) { - if (!dependency.fileName.startsWith('.')) continue; - const next = resolve(dirname(file), ...dependency.fileName.split('/')); - contained(image.directory, relative(image.directory, next).split(sep).join('/')); - pending.push(next); - } - } -} - -for (const name of ['Dockerfile']) { - test(`functions\\${name} compiles from its own inputs and ships its entry-point closure`, async t => { - const image = await materializeFunctionsImage(t, resolve('functions', name)); - await assertImageModuleClosure(image); - }); -} - -test('the gateway image compiles from its own inputs and ships its entry-point closure', async t => { - const image = await materializeFunctionsImage(t, resolve('Dockerfile')); - assert.equal(image.entry, 'dist/src/server.js'); - await assertImageModuleClosure(image); - for (const required of ['dist/src/server.js', 'public/app.js', 'package.json']) { - await readFile(contained(image.directory, required)); - } -}); - -test('the gateway clean room cannot borrow an omitted source directory from the checkout', async t => { - await assert.rejects(materializeFunctionsImage(t, resolve('Dockerfile'), text => - text.replace(/^COPY scripts \.\/scripts\r?$/m, '')), /Isolated image build failed/); -}); - -test('local image cannot borrow an omitted packaged runtime from the checkout', async t => { - const recipe = join(packagedContext, 'assets', 'functions', 'local.Dockerfile'); - const image = await materializeFunctionsImage(t, recipe, - text => text.replace(/^COPY dist\/src \.\/dist\/src\r?$/m, ''), packagedContext); - await assert.rejects(assertImageModuleClosure(image), /dist[\\/]src/); -}); - -test('local module closure rejects an omitted lock helper', async t => { - const recipe = join(packagedContext, 'assets', 'functions', 'local.Dockerfile'); - const image = await materializeFunctionsImage(t, recipe, text => text, packagedContext); - await rm(join(image.directory, 'dist', 'src', 'file-lock.mjs')); - await assert.rejects(assertImageModuleClosure(image), /file-lock\.mjs/); -}); - -test('local Functions image consumes only packaged implementation assets', async () => { - const recipe = await readFile(resolve('functions', 'local.Dockerfile'), 'utf8'); - assert.match(recipe, /COPY dist\/src \.\/dist\/src/); - assert.match(recipe, /COPY dist\/functions \.\/dist\/functions/); - assert.match(recipe, /COPY assets\/functions\/local-package\.json \.\/package\.json/); - assert.doesNotMatch(recipe, /COPY (?:src|scripts|functions) /); - assert.doesNotMatch(recipe, /tsc|tsconfig/); -}); diff --git a/tests/gateway.test.ts b/tests/gateway.test.ts index b71e948..4c89f2d 100644 --- a/tests/gateway.test.ts +++ b/tests/gateway.test.ts @@ -1,7 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createGateway, type GatewayDependencies } from '../src/gateway.js'; -import { objectKey, type StoredFile } from '../src/storage.js'; import type { RuntimeConfig } from '../src/config.js'; import { createServer, request as httpRequest } from 'node:http'; import { once } from 'node:events'; @@ -11,23 +10,16 @@ import { DependencyReadinessError } from '../src/readiness.js'; const config: RuntimeConfig = { tenantId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', apiClientId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', - dabUrl: 'https://data.internal', blobAccountUrl: 'https://store.blob.core.windows.net', - blobContainer: 'files', functionsUrl: 'https://functions.internal', publicDirectory: 'public', + requiredRole: 'AppUser', readinessPath: '/api/AppReady', + dabUrl: 'https://data.internal', publicDirectory: 'public', }; function dependencies(overrides: Partial = {}): GatewayDependencies { - const objects = new Map(); return { async verifyUser(token) { if (!['alice', 'bob'].includes(token)) throw new Error('Invalid token'); - return { oid: token, tenantId: config.tenantId }; - }, - files: { - async put(key, bytes) { objects.set(key, { bytes, contentType: 'application/octet-stream' }); }, - async get(key) { return objects.get(key); }, - async delete(key) { return objects.delete(key); }, + return { oid: token, tenantId: config.tenantId, ...(token === 'alice' ? { roles: ['AppUser'] } : {}) }; }, fetch: async () => new Response(JSON.stringify({ value: [] }), { headers: { 'content-type': 'application/json' } }), - functionToken: async () => 'gateway-service-token', ...overrides, }; } @@ -38,14 +30,16 @@ test('public frontend and auth config; API rejects missing/invalid tokens', asyn assert.equal((await app.inject('/auth/config')).json().clientId, config.apiClientId); assert.equal((await app.inject('/auth/config')).json().mode, 'entra'); assert.equal((await app.inject({ url: '/local/session', method: 'POST', payload: { user: 'alice' } })).statusCode, 404); - for (const url of ['/api/Todo', '/storage/test', '/auth/me']) { + for (const url of ['/api/Todo', '/auth/me']) { assert.equal((await app.inject(url)).statusCode, 401); assert.equal((await app.inject({ url, headers: { authorization: 'Bearer invalid' } })).statusCode, 401); } assert.equal((await app.inject({ url: '/graphql', method: 'POST', payload: {} })).statusCode, 401); - assert.equal((await app.inject({ url: '/functions/echo', method: 'POST', payload: {} })).statusCode, 401); + for (const url of ['/storage', '/jobs', '/functions/echo', '/diagnostics/traces']) { + assert.equal((await app.inject({ url, method: 'POST', payload: {} })).statusCode, 404, url); + } }); -test('data proxy forwards bearer token but drops client-supplied identity, role and cookies', async t => { +test('data proxy forwards bearer token, sets the required role and drops client-supplied identity and cookies', async t => { let target = ''; let forwarded = new Headers(); const app = await createGateway(config, dependencies({ @@ -64,7 +58,7 @@ test('data proxy forwards bearer token but drops client-supplied identity, role assert.equal(target, 'https://data.internal/api/Todo?$first=10'); assert.equal(forwarded.get('authorization'), 'Bearer alice'); assert.equal(forwarded.get('x-ms-client-principal'), null); - assert.equal(forwarded.get('x-ms-api-role'), null); + assert.equal(forwarded.get('x-ms-api-role'), 'AppUser'); assert.equal(forwarded.get('cookie'), null); assert.equal(result.headers['cache-control'], 'no-store'); }); @@ -85,7 +79,7 @@ test('data proxy forwards only the supported update-only If-Match value', async assert.deepEqual(received, ['*', null]); }); -test('data and function proxies stream upstream bodies without buffering them', async t => { +test('data proxy streams upstream bodies without buffering them', async t => { const app = await createGateway(config, dependencies({ fetch: async () => { const response = new Response(new ReadableStream({ @@ -105,12 +99,9 @@ test('data and function proxies stream upstream bodies without buffering them', const data = await app.inject({ url: '/api/Todo', headers }); assert.equal(data.statusCode, 200); assert.deepEqual(data.json(), { streamed: true }); - const invoked = await app.inject({ method: 'POST', url: '/functions/echo', headers, payload: {} }); - assert.equal(invoked.statusCode, 200); - assert.deepEqual(invoked.json(), { streamed: true }); }); -for (const { method, path } of [{ method: 'GET', path: '/api/stream' }, { method: 'POST', path: '/functions/echo' }]) { +for (const { method, path } of [{ method: 'GET', path: '/api/stream' }, { method: 'POST', path: '/graphql' }]) { test(`${path} cancels upstream when the client disconnects during a pending read`, { timeout: 5_000 }, async t => { let cancelled!: () => void; const upstreamClosed = new Promise(resolve => { cancelled = resolve; }); @@ -125,7 +116,7 @@ for (const { method, path } of [{ method: 'GET', path: '/api/stream' }, { method const address = upstream.address(); assert.ok(address && typeof address !== 'string'); const origin = `http://127.0.0.1:${address.port}`; - const app = await createGateway({ ...config, dabUrl: origin, functionsUrl: origin }, dependencies({ fetch })); + const app = await createGateway({ ...config, dabUrl: origin }, dependencies({ fetch })); t.after(async () => { upstream.closeAllConnections(); await app.close(); @@ -148,57 +139,6 @@ for (const { method, path } of [{ method: 'GET', path: '/api/stream' }, { method assert.equal(stopped, true, 'disconnect must cancel upstream without waiting for the 30-second proxy timeout'); }); } - -test('files are scoped by verified user; overwrite/delete and missing file behavior', async t => { - const app = await createGateway(config, dependencies()); - t.after(() => app.close()); - const alice = { authorization: 'Bearer alice', 'content-type': 'application/octet-stream' }; - assert.equal((await app.inject({ method: 'PUT', url: '/storage/note.txt', headers: alice, payload: Buffer.from('private') })).statusCode, 204); - assert.equal((await app.inject({ url: '/storage/note.txt', headers: { authorization: 'Bearer bob' } })).statusCode, 404); - const own = await app.inject({ url: '/storage/note.txt', headers: { authorization: 'Bearer alice' } }); - assert.equal(own.body, 'private'); - assert.equal(own.headers['content-disposition'], 'attachment'); - assert.equal((await app.inject({ method: 'DELETE', url: '/storage/note.txt', headers: { authorization: 'Bearer bob' } })).statusCode, 404); - assert.equal((await app.inject({ method: 'PUT', url: '/storage/note.txt', headers: alice, payload: Buffer.from('updated') })).statusCode, 204); - assert.equal((await app.inject({ method: 'DELETE', url: '/storage/note.txt', headers: { authorization: 'Bearer alice' } })).statusCode, 204); -}); -test('file limits and unsafe names are rejected', async t => { - const app = await createGateway(config, dependencies()); - t.after(() => app.close()); - assert.throws(() => objectKey('tenant', 'alice', '../other')); - assert.throws(() => objectKey('tenant', 'alice', 'a/b')); - const limit = 4 * 1024 * 1024; - const upload = await app.inject({ - method: 'PUT', url: '/storage/exact-limit', - headers: { authorization: 'Bearer alice', 'content-type': 'application/octet-stream' }, - payload: Buffer.alloc(limit, 65), - }); - assert.equal(upload.statusCode, 204); - const download = await app.inject({ - url: '/storage/exact-limit', headers: { authorization: 'Bearer alice' }, - }); - assert.equal(download.statusCode, 200); - assert.equal(download.rawPayload.length, limit); - assert.equal(download.rawPayload.equals(Buffer.alloc(limit, 65)), true); - assert.equal((await app.inject({ method: 'PUT', url: '/storage/file', headers: { authorization: 'Bearer alice', 'content-type': 'application/octet-stream' }, payload: Buffer.alloc(4 * 1024 * 1024 + 1) })).statusCode, 413); - assert.equal((await app.inject({ method: 'PUT', url: '/storage/file', headers: { authorization: 'Bearer alice' }, payload: { bad: 'json' } })).statusCode, 415); -}); -test('function invocation substitutes service identity and carries validated user separately', async t => { - let headers = new Headers(); - let body: unknown; - const app = await createGateway(config, dependencies({ - fetch: async (_url, options) => { - headers = new Headers(options?.headers); - body = JSON.parse(String(options?.body)); - return new Response('{"result":"ok"}'); - }, - })); - t.after(() => app.close()); - const response = await app.inject({ method: 'POST', url: '/functions/echo', headers: { authorization: 'Bearer alice' }, payload: { hello: 'Azure' } }); - assert.equal(response.statusCode, 200); - assert.equal(headers.get('authorization'), 'Bearer gateway-service-token'); - assert.deepEqual(body, { user: { oid: 'alice', tenantId: config.tenantId }, input: { hello: 'Azure' } }); -}); test('dependency errors are explicit; readiness fails closed', async t => { const app = await createGateway(config, dependencies({ fetch: async () => { throw new Error('dependency secret'); } })); t.after(() => app.close()); @@ -211,118 +151,35 @@ test('dependency errors are explicit; readiness fails closed', async t => { test('readiness exposes only a safe dependency identifier and never its private cause', async t => { const app = await createGateway(config, dependencies({ - ready: async () => { throw new DependencyReadinessError('functions', new Error('synthetic private credential')); }, + ready: async () => { throw new DependencyReadinessError('data', new Error('synthetic private credential')); }, })); t.after(() => app.close()); const warning = t.mock.method(app.log, 'warn'); const response = await app.inject('/health/ready'); assert.equal(response.statusCode, 503); - assert.deepEqual(response.json(), { status: 'not-ready', dependency: 'functions' }); + assert.deepEqual(response.json(), { status: 'not-ready', dependency: 'data' }); assert.equal(response.body.includes('synthetic private credential'), false); assert.equal(JSON.stringify(warning.mock.calls.map(call => call.arguments)).includes('synthetic private credential'), false); assert.equal((await app.inject('/health/live')).statusCode, 200); }); -test('file listing and jobs use verified identity and refuse caller-managed fields', async t => { - const id = '11111111-1111-4111-8111-111111111111'; - const deps = dependencies({ - processing: { - async list(identity) { assert.equal(identity.oid, 'alice'); return []; }, - async create(identity, name) { - assert.equal(identity.oid, 'alice'); assert.equal(name, 'file.txt'); return id; - }, - async delete(identity, value) { assert.equal(identity.oid, 'alice'); return value === id; }, - }, - }); - let listArguments: unknown[] = []; - Object.assign(deps.files, { list: async (...args: unknown[]) => { - listArguments = args; - return { files: [{ name: 'file.txt', size: 3 }], continuationToken: 'next-page' }; - } }); - const app = await createGateway(config, deps); - t.after(() => app.close()); - const headers = { authorization: 'Bearer alice', 'x-ms-client-principal': 'spoofed', 'x-ms-api-role': 'processor' }; - for (const url of ['/storage', '/jobs']) assert.equal((await app.inject(url)).statusCode, 401); - assert.deepEqual((await app.inject({ url: '/storage?continuationToken=current-page', headers })).json(), - { files: [{ name: 'file.txt', size: 3 }], continuationToken: 'next-page' }); - assert.deepEqual(listArguments, [ - `${config.tenantId}/alice/`, { continuationToken: 'current-page', maxPageSize: 100 }, - ]); - assert.deepEqual((await app.inject({ url: '/jobs', headers })).json(), { jobs: [] }); - for (const payload of [{ name: '../other' }, { name: 'file.txt', status: 'completed' }, { name: 'file.txt', owner_oid: 'bob' }]) { - assert.equal((await app.inject({ url: '/jobs', method: 'POST', headers, payload })).statusCode, 400); - } - const accepted = await app.inject({ url: '/jobs', method: 'POST', headers, payload: { name: 'file.txt' } }); - assert.equal(accepted.statusCode, 202); - assert.deepEqual(accepted.json(), { id }); - assert.equal((await app.inject({ url: '/jobs/not-a-guid', method: 'DELETE', headers })).statusCode, 400); - assert.equal((await app.inject({ url: `/jobs/${id}`, method: 'DELETE', headers })).statusCode, 204); -}); - -test('production gateway does not enable local-only file processing', async t => { - const app = await createGateway(config, dependencies()); - t.after(() => app.close()); - assert.equal(Boolean((await app.inject('/auth/config')).json().capabilities.processing), false); - const headers = { authorization: 'Bearer alice' }; - assert.equal((await app.inject({ url: '/jobs', headers })).statusCode, 503); - assert.equal((await app.inject({ url: '/jobs', method: 'POST', headers, payload: { name: 'file.txt' } })).statusCode, 503); - assert.equal((await app.inject({ url: '/diagnostics/traces', headers })).statusCode, 503); -}); - -test('retry routes authorize identity and reject invalid IDs or caller-managed payloads', async t => { - const id = '11111111-1111-4111-8111-111111111111'; - const app = await createGateway(config, dependencies({ processing: { - async create() { return id; }, async list() { return []; }, async delete() { return false; }, - async retry(identity, jobId) { assert.equal(identity.oid, 'alice'); assert.equal(jobId, id); return id; }, - } })); - t.after(() => app.close()); - assert.equal((await app.inject({ method: 'POST', url: `/jobs/${id}/retry` })).statusCode, 401); - const headers = { authorization: 'Bearer alice' }; - assert.equal((await app.inject({ method: 'POST', url: '/jobs/invalid/retry', headers })).statusCode, 400); - assert.equal((await app.inject({ method: 'POST', url: `/jobs/${id}/retry`, headers, payload: { owner_oid: 'bob' } })).statusCode, 400); - assert.equal((await app.inject({ method: 'POST', url: `/jobs/${id}/retry`, headers })).statusCode, 202); -}); - -test('foundation current-user responses retain top-level identity and report allowed access', async t => { +test('current-user responses retain top-level identity and report allowed access', async t => { const app = await createGateway(config, dependencies()); t.after(() => app.close()); const response = await app.inject({ url: '/auth/me', headers: { authorization: 'Bearer alice' } }); assert.equal(response.statusCode, 200); assert.equal(response.headers['cache-control'], 'no-store'); - assert.deepEqual(response.json(), { oid: 'alice', tenantId: config.tenantId, access: { allowed: true } }); -}); - -test('role-based gateways reject a missing application role before startup', async () => { - for (const requiredRole of [undefined, '']) { - await assert.rejects(async () => { - const app = await createGateway({ ...config, profile: 'role-based-data', - ...(requiredRole === undefined ? {} : { requiredRole }) }, dependencies()); - await app.close(); - }, /Role-based-data requires a configured application role/); - } + assert.deepEqual(response.json(), { oid: 'alice', tenantId: config.tenantId, roles: ['AppUser'], access: { allowed: true } }); }); -test('telemetry startup failures are server errors, not rejected access tokens', async t => { +test('users without the required role are refused before the data proxy', async t => { let upstream = 0; - let tracing = 0; - const unavailable = async (): Promise => { throw new Error('Unexpected telemetry operation'); }; - const app = await createGateway(config, dependencies({ - telemetry: { - begin() { tracing++; throw new Error('Telemetry startup failed'); }, - end: unavailable, operation: unavailable, list: async () => [], shutdown: async () => {}, - }, - fetch: async () => { upstream++; return Response.json({ value: [] }); }, - })); + const app = await createGateway(config, dependencies({ fetch: async () => { upstream++; return Response.json({ value: [] }); } })); t.after(() => app.close()); - const headers = { authorization: 'Bearer alice' }; - assert.equal((await app.inject({ url: '/diagnostics/traces', headers })).statusCode, 200); - assert.equal((await app.inject({ url: '/jobs', headers })).statusCode, 503); - assert.equal((await app.inject({ url: '/api/Todo', headers: { authorization: 'Bearer invalid' } })).statusCode, 401); - assert.equal(tracing, 0); - const response = await app.inject({ url: '/api/Todo', headers }); - assert.equal(response.statusCode, 500); - assert.equal(response.json().error, 'Service operation failed'); - assert.equal(tracing, 1); + const headers = { authorization: ['Bearer', 'bob'].join(' ') }; + assert.equal((await app.inject({ url: '/api/AppReady', headers })).statusCode, 403); + assert.equal((await app.inject({ url: '/graphql', method: 'POST', headers, payload: {} })).statusCode, 403); + assert.deepEqual((await app.inject({ url: '/auth/me', headers })).json().access, { allowed: false, requiredRole: 'AppUser' }); assert.equal(upstream, 0); }); diff --git a/tests/guide.test.mjs b/tests/guide.test.mjs index d537e69..0b88ded 100644 --- a/tests/guide.test.mjs +++ b/tests/guide.test.mjs @@ -28,7 +28,7 @@ async function fixture(t) { t.after(() => rm(home, { recursive: true, force: true })); for (const folder of ['src', 'sql', 'public', 'dab', '.config']) await mkdir(join(home, folder), { recursive: true }); await writeFile(join(home, 'package.json'), JSON.stringify({ - name: 'fixture', dependencies: { '@microsoft/sql-apps': '0.1.2' }, + name: 'fixture', dependencies: { '@microsoft/sql-apps': '0.2.0' }, })); await writeFile(join(home, 'package-lock.json'), JSON.stringify({ lockfileVersion: 3 })); await writeFile(join(home, 'tsconfig.json'), JSON.stringify({ compilerOptions: {} })); @@ -36,7 +36,7 @@ async function fixture(t) { await writeFile(join(home, '.sql-apps-project.json'), JSON.stringify({ version: 1, id: 'b91d4857-9570-4d4b-a740-918471ca7e26', name: 'Fixture', slug: 'fixture', starter: 'data', profile: 'role-based-data', scaffoldVersion: 1, - tooling: { package: '@microsoft/sql-apps', version: '0.1.2' }, + tooling: { package: '@microsoft/sql-apps', version: '0.2.0' }, })); await writeFile(join(home, 'application.json'), JSON.stringify({ name: 'fixture', selectedExamples: [] })); await writeFile(join(home, 'role-based-data.json'), JSON.stringify({ @@ -76,23 +76,17 @@ test('guide routes run-locally through setup plan and suggests the profile that let report = await readGuide(home); assert.equal(report.nextStage, 'run-locally'); assert.equal(report.profile, 'role-based-data'); - assert.equal(report.suggestedProfile, 'role-based-data', 'a data-only brief must not be steered to the file/job foundation'); - assert.match(report.nextAction, /setup plan --profile role-based-data/); + assert.equal(report.suggestedProfile, 'role-based-data', 'every brief uses the single data profile'); + assert.match(report.nextAction, /setup plan/); assert.doesNotMatch(report.nextAction, /setup-check/, 'the coordinator, not the raw readiness check, orders approvals'); - assert.match(report.cost.reviewCommand, /role-based-cost/); - await saveBrief(home, { ...brief(), capabilities: ['data', 'files', 'background-jobs'] }); - report = await readGuide(home); - assert.equal(report.suggestedProfile, 'role-based-data', - 'the generated project profile remains authoritative when the brief outgrows its starter'); - assert.match(report.nextAction, /setup plan --profile role-based-data/); - assert.match(report.cost.reviewCommand, /role-based-cost/); + assert.match(report.cost.reviewCommand, /azure-cost/); await writeFile(join(home, 'role-based-data.json'), '{}'); report = await readGuide(home); assert.equal(report.profile, 'role-based-data'); assert.equal(report.suggestedProfile, 'role-based-data', 'a configured profile wins over the brief'); - assert.match(report.nextAction, /setup plan --profile role-based-data/); - assert.match(report.cost.reviewCommand, /role-based-cost .*azure-role-based-cost\.example\.json/); - assert.doesNotMatch(report.cost.reviewCommand, /demo-cost/); + assert.match(report.nextAction, /setup plan/); + assert.match(report.cost.reviewCommand, /azure-cost/); + assert.doesNotMatch(report.cost.reviewCommand, /demo-cost|role-based-cost/); }); test('guide persists decisions and reports evidence as historical, never current acceptance', async t => { @@ -122,7 +116,7 @@ test('source changes invalidate reported progress and require local re-verificat assert.equal(report.progress.status, 'source-changed'); assert.equal(report.nextStage, 'run-locally'); assert.match(report.nextAction, /rebuild.*verify/i); - assert.match(report.nextAction, /setup plan --profile role-based-data/, 'a stale checkpoint still routes through the guarded coordinator'); + assert.match(report.nextAction, /setup plan/, 'a stale checkpoint still routes through the guarded coordinator'); assert.equal(report.brief.nextChange, input.nextChange); }); @@ -134,7 +128,7 @@ test('implementing the app after a checkpoint-free brief keeps the setup-plan ro assert.equal(report.progress.status, 'source-changed'); assert.equal(report.nextStage, 'run-locally'); assert.doesNotMatch(report.nextAction, /checkpoint/, 'no checkpoint was ever saved'); - assert.match(report.nextAction, /setup plan --profile role-based-data/); + assert.match(report.nextAction, /setup plan/); }); test('guide records checkout-specific workspace selection without authorizing startup', async t => { @@ -165,8 +159,7 @@ test('strict brief validation rejects unsupported choices, blank values and inve { ...brief(), checkpoint: { stage: 'run-locally', summary: 'Works', evidence: ['ok'], verified: true } }, ]) assert.throws(() => validateBrief(input), /brief|capabilit|checkpoint/i); assert.deepEqual(validateBrief(brief()), brief()); - assert.deepEqual(validateBrief({ ...brief(), capabilities: ['data', 'files', 'background-jobs'] }).capabilities, - ['data', 'files', 'background-jobs']); + assert.throws(() => validateBrief({ ...brief(), capabilities: ['data', 'files', 'background-jobs'] }), /maintained Azure skills/); }); test('invalid, oversized and copied state fail explicitly and are never overwritten', async t => { @@ -268,20 +261,17 @@ test('coordinator entry points use the local-first journey and retain sharing bo 'the beginner entry point should defer hosting topology to the sharing guide'); assert.match(readme, /docs\/guides\/sharing\.md/); const sharing = await readFile('docs/guides/sharing.md', 'utf8'); - const demoDeployment = await readFile('docs/reference/demo-deployment.md', 'utf8'); - assert.match(sharing, /Static Web Apps.*website.*Container App.*API.*Data API Builder/is); - assert.match(sharing, /one public URL/is); - assert.match(sharing, /Standard.*not automatically cheaper/is); - assert.match(demoDeployment, /demo-publish.*--approve/is); - assert.match(demoDeployment, /hosting-compare/is); - assert.match(demoDeployment, /direct.*backend.*denied/is); + assert.match(sharing, /azure-prepare.*azure-validate.*azure-deploy/is); + assert.match(sharing, /azure-cost/); + assert.match(sharing, /role-based-data/); + assert.doesNotMatch(sharing, /demo-publish|hosting-compare|demo-cost/); const guideReference = await readFile('docs/reference/guide.md', 'utf8'); assert.match(guideReference, /historical/i); assert.match(guideReference, /does not authorize.*cloud deployment/); assert.equal((await readFile('content/start.md', 'utf8')).includes('"'), false); const cloud = await readFile('plugins/sql-apps/skills/sql-apps-cloud-preview/SKILL.md', 'utf8'); assert.match(cloud, /Sharing is optional/); - assert.match(cloud, /Load only the material needed for the request/); + assert.match(cloud, /azure-prepare[\s\S]*azure-validate[\s\S]*azure-deploy/); }); test('agent guidance stays lean, scoped and progressively disclosed', async () => { @@ -308,22 +298,11 @@ test('agent guidance stays lean, scoped and progressively disclosed', async () = const local = await readFile('plugins/sql-apps/skills/sql-apps-local/SKILL.md', 'utf8'); assert.doesNotMatch(local, /docs\/reference\/deployment\.md|demo-cost|role-based-cost/, - 'local startup guidance must leave deployment and cloud cost work to cloud-preview'); + 'local startup guidance must leave deployment and cloud cost work to the Azure skills route'); await access('docs/maintainers/agent-evaluations.md'); }); -test('hosting decision records the candidate architecture and promotion gate', async () => { - const hostingDecision = await readFile('docs/maintainers/hosting-decision.md', 'utf8'); - assert.match(hostingDecision, /Database Connections.*retired.*November 30, 2025/is); - assert.match(hostingDecision, /Static Web Apps Standard.*linked.*Container App/is); - assert.match(hostingDecision, /Node API.*DAB.*sidecar/is); - assert.match(hostingDecision, /not.*assumed.*cheaper/is); - assert.match(hostingDecision, /role-based-data.*full-foundation.*unchanged/is); - assert.doesNotMatch(hostingDecision, - /Lovable|Bolt|Replit|v0\/Vercel|competitor|market (?:research|opportunity)/i); -}); - test('reader documentation, skill references and icons resolve after audience-based reorganization', async () => { const files = ['README.md', 'CONTRIBUTING.md', 'SUPPORT.md', 'SECURITY.md', 'skills/sql-apps-getting-started/SKILL.md', diff --git a/tests/hosting-comparison.test.ts b/tests/hosting-comparison.test.ts deleted file mode 100644 index 2cf79fb..0000000 --- a/tests/hosting-comparison.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import { compareHosting } from '../src/hosting-comparison.js'; - -const now = new Date('2026-10-11T15:30:23-05:00'); -const evidence = { - version: 1, - profile: 'public-demo', - region: 'eastus', - currency: 'USD', - checkedOn: '2026-10-11', - workload: { - monthlyRequests: 10000, - monthlyDataTransferGb: 1, - activeBackendHours: 20, - }, - current: { - hosting: 'container-apps', - monthlyCost: 12.34, - source: 'https://prices.azure.com/', - }, - candidate: { - hosting: 'static-web-apps', - monthlyCost: 21.34, - source: 'https://prices.azure.com/', - }, - liveAcceptance: false, -} as const; - -test('keeps Container Apps as the default when the candidate costs more', () => { - const result = compareHosting(evidence, now); - assert.equal(result.recommendedDefault, 'container-apps'); - assert.equal(result.deltaMonthly, 9); - assert.ok(Math.abs((result.deltaPercent ?? 0) - 72.9335494327) < 0.000001); - assert.equal(result.reason, 'candidate-costs-more'); - assert.equal(result.defaultChangeAuthorized, false); -}); - -test('recommends an accepted candidate that costs no more without authorizing a change', () => { - const result = compareHosting({ - ...evidence, - candidate: { ...evidence.candidate, monthlyCost: 10 }, - liveAcceptance: true, - }, now); - assert.equal(result.recommendedDefault, 'static-web-apps'); - assert.equal(result.reason, 'candidate-costs-no-more-and-is-live-accepted'); - assert.equal(result.defaultChangeAuthorized, false); -}); - -test('does not promote a cheaper candidate before live acceptance', () => { - const result = compareHosting({ - ...evidence, - candidate: { ...evidence.candidate, monthlyCost: 10 }, - }, now); - assert.equal(result.recommendedDefault, 'container-apps'); - assert.equal(result.reason, 'candidate-not-live-accepted'); -}); - -test('rejects future, stale, insecure, placeholder, and invalid cost evidence', () => { - for (const input of [ - { ...evidence, checkedOn: '2026-10-12' }, - { ...evidence, checkedOn: '2026-09-10' }, - { ...evidence, current: { ...evidence.current, source: 'http://prices.azure.com/' } }, - { ...evidence, current: { ...evidence.current, monthlyCost: 0 } }, - { ...evidence, current: { ...evidence.current, monthlyCost: -1 } }, - { ...evidence, candidate: { ...evidence.candidate, monthlyCost: Number.POSITIVE_INFINITY } }, - ]) assert.throws(() => compareHosting(input, now)); -}); - -test('rejects cost entries that claim a different region, currency, or workload basis', () => { - for (const candidate of [ - { ...evidence.candidate, region: 'westus' }, - { ...evidence.candidate, currency: 'EUR' }, - { ...evidence.candidate, workload: { ...evidence.workload, monthlyRequests: 1 } }, - ]) assert.throws(() => compareHosting({ ...evidence, candidate }, now)); -}); - -test('example uses rejected placeholders and cannot promote a default', async () => { - const example = JSON.parse(await readFile('azure-demo-hosting-comparison.example.json', 'utf8')); - assert.equal(example.current.monthlyCost, 0); - assert.equal(example.candidate.monthlyCost, 0); - assert.equal(example.liveAcceptance, false); - assert.throws(() => compareHosting(example, now)); -}); diff --git a/tests/local-app.test.ts b/tests/local-app.test.ts index bc602c8..24aadfd 100644 --- a/tests/local-app.test.ts +++ b/tests/local-app.test.ts @@ -3,24 +3,25 @@ import assert from 'node:assert/strict'; import { createLocalApp, developmentUsers, localAppOrigin, startLocalGateway, waitForLocalReadiness } from '../src/local-app.js'; import { readFile } from 'node:fs/promises'; import { runtimeFor } from '../src/workspace.mjs'; -import { localSettingsSchema } from '../src/local-settings.js'; -import { DependencyReadinessError } from '../src/readiness.js'; import { z } from 'zod'; import { request } from 'node:http'; import { generateKeyPair, SignJWT } from 'jose'; const headers = { host: new URL(localAppOrigin).host, origin: localAppOrigin }; -type Services = NonNullable[1]>; -const serviceFixture = (ready: Services['ready']): Services => ({ - files: { put: async () => {}, get: async () => undefined, delete: async () => false }, - processing: { - list: async () => [], create: async () => '11111111-1111-4111-8111-111111111111', - delete: async () => false, - }, - functionToken: async () => 'synthetic-test-token', - settings: localSettingsSchema.parse({ tracing: false }), - ready, -}); +const profile = { profile: 'role-based-data', requiredRole: 'AppUser', readinessPath: '/api/AppReady' } as const; +const bearer = (token: string) => ['Bearer', token].join(' '); +const unexpected: typeof fetch = async () => { throw new Error('Unexpected data request'); }; +const healthy: typeof fetch = async () => Response.json({ value: [] }); +const flaky = (fails: () => boolean): typeof fetch => async () => fails() + ? new Response(null, { status: 503 }) : Response.json({ value: [] }); +const oidOf = (forwarded: Headers) => JSON.parse(Buffer.from(forwarded.get('x-ms-client-principal')!, 'base64').toString()) + .claims.find((claim: { typ: string }) => claim.typ === 'oid').val; + +async function session(app: Awaited>, user: string) { + const response = await app.inject({ url: '/local/session', method: 'POST', headers, payload: { user } }); + assert.equal(response.statusCode, 200); + return z.object({ token: z.string() }).parse(response.json()).token; +} function loopbackGateway(t: TestContext, app: Awaited>) { const listen = app.listen.bind(app); @@ -59,100 +60,65 @@ function loopbackGateway(t: TestContext, app: Awaited { - const app = await createLocalApp(async () => Response.json({ value: [] }), - serviceFixture(async () => { throw new DependencyReadinessError('functions', new Error('worker unavailable')); })); +test('readiness uses one bounded read of the readiness procedure as the role holder', async t => { + const requests: URL[] = []; + const app = await createLocalApp(async (url, options) => { + const target = new URL(String(url)); + requests.push(target); + assert.equal(target.pathname, '/api/AppReady'); + assert.equal(options?.redirect, 'error'); + assert.ok(options?.signal); + const forwarded = new Headers(options?.headers); + assert.equal(forwarded.get('authorization'), null); + assert.equal(forwarded.get('x-ms-api-role'), 'AppUser'); + assert.equal(oidOf(forwarded), developmentUsers[0]!.id); + return Response.json({ value: [{ ready: 1 }] }); + }, profile); t.after(() => app.close()); - const readiness = await app.inject({ url: '/health/ready', headers }); - assert.equal(readiness.statusCode, 503); - assert.deepEqual(readiness.json(), { status: 'not-ready', dependency: 'functions' }); - assert.equal((await app.inject({ url: '/health/live', headers })).statusCode, 200); + const response = await app.inject({ url: '/health/ready', headers }); + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json(), { status: 'ready' }); + assert.equal(requests.length, 1); }); -for (const foundation of [false, true]) { - test(`${foundation ? 'foundation' : 'SQL-only'} readiness uses one bounded authorized data read`, async t => { - const requests: URL[] = []; - let servicesChecked = false; - const fetcher: typeof fetch = async (url, options) => { - const target = new URL(String(url)); - requests.push(target); - if (target.pathname === '/health') return Response.json({ status: 'Unhealthy' }); - assert.equal(target.pathname, '/api/FileJob'); - assert.equal(target.searchParams.get('$first'), '1'); - assert.equal(options?.redirect, 'error'); - assert.ok(options?.signal); - const forwarded = new Headers(options?.headers); - assert.equal(forwarded.get('authorization'), null); - assert.equal(forwarded.get('x-ms-api-role'), 'authenticated'); - const principal = JSON.parse(Buffer.from(forwarded.get('x-ms-client-principal')!, 'base64').toString()); - assert.equal(principal.claims.find((claim: { typ: string }) => claim.typ === 'oid').val, developmentUsers[0]!.id); - return Response.json({ value: [], nextLink: '/api/FileJob?$after=unused' }); - }; - const app = await createLocalApp(fetcher, foundation ? serviceFixture(async (usedFetcher, signal) => { - assert.equal(usedFetcher, fetcher); - assert.equal(signal.aborted, false); - servicesChecked = true; - }) : undefined); - t.after(() => app.close()); - const response = await app.inject({ url: '/health/ready', headers }); - assert.equal(response.statusCode, 200); - assert.deepEqual(response.json(), { status: 'ready' }); - assert.equal(requests.length, 1); - assert.equal(requests[0]!.pathname, '/api/FileJob'); - assert.equal(servicesChecked, foundation); - }); -} - for (const scenario of [ { name: 'authorization failure', response: () => new Response(null, { status: 403 }) }, { name: 'data outage', response: () => new Response(null, { status: 503 }) }, { name: 'connection refusal', response: (): Response => { throw new Error('synthetic private failure'); } }, - { name: 'invalid JSON', response: () => new Response('not-json') }, - { name: 'wrong data shape', response: () => Response.json({ status: 'Healthy' }) }, - { name: 'oversized page', response: () => Response.json({ value: [ - { id: developmentUsers[0]!.id }, { id: developmentUsers[1]!.id }, - ] }) }, - { name: 'invalid id', response: () => Response.json({ value: [{ id: 'wrong' }] }) }, ]) { test(`local data readiness fails closed on ${scenario.name}`, async t => { - const app = await createLocalApp(async () => scenario.response()); + const app = await createLocalApp(async () => scenario.response(), profile); t.after(() => app.close()); const response = await app.inject({ url: '/health/ready', headers }); assert.equal(response.statusCode, 503); assert.deepEqual(response.json(), { status: 'not-ready', dependency: 'data' }); assert.equal(response.body.includes('synthetic private failure'), false); + assert.equal((await app.inject({ url: '/health/live', headers })).statusCode, 200); }); } -test('one four-second deadline is shared by the authorized data and service probes', async t => { +test('each readiness probe has one four-second deadline', async t => { const deadlines: number[] = []; const nativeTimeout = AbortSignal.timeout.bind(AbortSignal); t.mock.method(AbortSignal, 'timeout', (milliseconds: number) => { deadlines.push(milliseconds); return nativeTimeout(milliseconds); }); - let dataSignal: AbortSignal | null | undefined; - const app = await createLocalApp(async (_url, options) => { - dataSignal = options?.signal; - return Response.json({ value: [] }); - }, serviceFixture(async (_fetcher, signal) => { assert.equal(signal, dataSignal); })); + const app = await createLocalApp(healthy, profile); t.after(() => app.close()); assert.equal((await app.inject({ url: '/health/ready', headers })).statusCode, 200); assert.deepEqual(deadlines, [4_000]); }); -test('missing worker prevents initial listen and closes the new app after bounded retries', async () => { +test('unavailable data prevents initial listen and closes the new app after bounded retries', async () => { let attempts = 0; let waits = 0; let closed = false; - const app = await createLocalApp(async () => Response.json({ value: [] }), serviceFixture(async () => { - attempts++; - throw new DependencyReadinessError('functions', new Error('synthetic private failure')); - })); + const app = await createLocalApp(flaky(() => { attempts++; return true; }), profile); app.addHook('onClose', async () => { closed = true; }); await assert.rejects(startLocalGateway(app, runtimeFor().defaultSql, async () => { waits++; }), error => error instanceof Error && /no ready URL is published/.test(error.message) && - error.cause instanceof Error && /functions.*three read-only attempts/.test(error.cause.message)); + error.cause instanceof Error && /data readiness failed after three read-only attempts/.test(error.cause.message)); assert.equal(attempts, 3); assert.equal(waits, 2); assert.equal(app.server.listening, false); @@ -169,11 +135,7 @@ for (const failsAfterListen of [false, true]) { deadlines.push(milliseconds); return timeout(milliseconds); }); - const app = await createLocalApp(async () => Response.json({ value: [] }), serviceFixture(async () => { - if (++probes > 1 && failsAfterListen) { - throw new DependencyReadinessError('functions', new Error('synthetic private worker credential')); - } - })); + const app = await createLocalApp(flaky(() => ++probes > 1 && failsAfterListen), profile); t.after(() => app.close()); app.addHook('onClose', async () => { closed = true; }); const responses = loopbackGateway(t, app); @@ -182,11 +144,10 @@ for (const failsAfterListen of [false, true]) { if (failsAfterListen) { await assert.rejects(started, error => error instanceof Error && /no ready URL is published/.test(error.message) && error.cause instanceof Error && - /functions readiness did not pass initial HTTP confirmation/.test(error.cause.message) && - !error.cause.message.includes('synthetic private')); + /data readiness did not pass initial HTTP confirmation/.test(error.cause.message)); assert.equal(app.server.listening, false); assert.equal(closed, true); - assert.deepEqual(responses, [{ status: 503, body: { status: 'not-ready', dependency: 'functions' } }]); + assert.deepEqual(responses, [{ status: 503, body: { status: 'not-ready', dependency: 'data' } }]); } else { await started; assert.equal(app.server.listening, true); @@ -200,25 +161,21 @@ for (const failsAfterListen of [false, true]) { test('initial readiness and cleanup errors are both preserved', async () => { const cleanup = new Error('synthetic gateway cleanup failure'); - const app = await createLocalApp(async () => Response.json({ value: [] }), serviceFixture(async () => { - throw new DependencyReadinessError('functions', new Error('synthetic private worker failure')); - })); + const app = await createLocalApp(flaky(() => true), profile); app.addHook('onClose', async () => { throw cleanup; }); await assert.rejects(startLocalGateway(app, runtimeFor().defaultSql, async () => {}), error => error instanceof AggregateError && /no ready URL is published/.test(error.message) && error.errors.length === 2 && error.errors[0] instanceof Error && - /functions readiness failed after three read-only attempts/.test(error.errors[0].message) && + /data readiness failed after three read-only attempts/.test(error.errors[0].message) && error.errors[1] === cleanup); assert.equal(app.server.listening, false); }); for (const becomesReady of [2, 3]) { - test(`initial readiness stops retrying when the worker recovers on attempt ${becomesReady}`, async t => { + test(`initial readiness stops retrying when data recovers on attempt ${becomesReady}`, async t => { let attempts = 0; let waits = 0; - const app = await createLocalApp(async () => Response.json({ value: [] }), serviceFixture(async () => { - if (++attempts < becomesReady) throw new DependencyReadinessError('functions', new Error('starting')); - })); + const app = await createLocalApp(flaky(() => ++attempts < becomesReady), profile); t.after(() => app.close()); await waitForLocalReadiness(app, async () => { waits++; }); assert.equal(attempts, becomesReady); @@ -227,64 +184,24 @@ for (const becomesReady of [2, 3]) { }); } -test('SQL-only initial readiness does not require service credentials or worker probes', async t => { - const app = await createLocalApp(async () => Response.json({ value: [] })); - t.after(() => app.close()); - await waitForLocalReadiness(app, async () => { assert.fail('healthy SQL-only must not retry'); }); -}); - -test('later worker outage does not gate durable submission and readiness recovers in place', async t => { - let missing = false; - const app = await createLocalApp(async () => Response.json({ value: [] }), serviceFixture(async () => { - if (missing) throw new DependencyReadinessError('functions', new Error('worker unavailable')); - })); - t.after(() => app.close()); - await waitForLocalReadiness(app, async () => {}); - missing = true; - const health = await app.inject({ url: '/health/ready', headers }); - assert.equal(health.statusCode, 503); - assert.deepEqual(health.json(), { status: 'not-ready', dependency: 'functions' }); - assert.equal((await app.inject({ url: '/health/live', headers })).statusCode, 200); - const capabilities = (await app.inject({ url: '/auth/config', headers })).json().capabilities; - assert.equal(capabilities.jobSubmission, true); - assert.equal(capabilities.functions, true); - const session = await app.inject({ - method: 'POST', url: '/local/session', headers, payload: { user: developmentUsers[0]!.id }, - }); - const token = z.object({ token: z.string() }).parse(session.json()).token; - const authenticated = { ...headers, authorization: `Bearer ${token}` }; - const queued = await app.inject({ - method: 'POST', url: '/jobs', headers: authenticated, payload: { name: 'readiness-fixture.txt' }, - }); - assert.equal(queued.statusCode, 202); - assert.deepEqual(queued.json(), { id: '11111111-1111-4111-8111-111111111111' }); - assert.equal((await app.inject({ url: '/jobs', headers: authenticated })).statusCode, 200); - missing = false; - assert.equal((await app.inject({ url: '/health/ready', headers })).statusCode, 200); -}); - -test('local app serves frontend and explicit SQL-only development capabilities', async t => { - const app = await createLocalApp(); +test('local app serves the frontend and persona-free preview configuration', async t => { + const app = await createLocalApp(unexpected, profile); t.after(() => app.close()); assert.equal((await app.inject({ url: '/', headers })).statusCode, 200); - const config = (await app.inject({ url: '/auth/config', headers })).json(); - assert.equal(config.mode, 'local'); - assert.deepEqual(config.capabilities, { files: false, functions: false }); - assert.equal('users' in config, false); - assert.equal('clientId' in config, false); + assert.deepEqual((await app.inject({ url: '/auth/config', headers })).json(), { mode: 'local', readinessPath: '/api/AppReady' }); assert.equal((await app.inject({ url: '/auth/me', headers })).statusCode, 401); const users = await app.inject({ url: '/local/users', headers }); assert.equal(users.statusCode, 200); - assert.deepEqual(users.json().users, developmentUsers); + assert.deepEqual(users.json().users.map((user: { id: string }) => user.id), developmentUsers.map(user => user.id)); assert.equal(users.headers['cache-control'], 'no-store'); }); -test('local workspace identity distinguishes the selected capability profile', async t => { - const app = await createLocalApp(); +test('local workspace identity reports the role-based profile and build', async t => { + const app = await createLocalApp(unexpected, profile); t.after(() => app.close()); const identity = (await app.inject({ url: '/local/workspace', headers })).json(); assert.equal(identity.version, 1); - assert.equal(identity.profile, 'sql-only'); + assert.equal(identity.profile, 'role-based-data'); assert.equal(identity.origins.app, localAppOrigin); assert.equal(identity.container, runtimeFor().defaultSql); assert.equal(identity.sourceFingerprint, JSON.parse(await readFile('dist/build-provenance.json', 'utf8')).sourceFingerprint); @@ -292,13 +209,13 @@ test('local workspace identity distinguishes the selected capability profile', a }); test('local workspace identity names the explicitly selected SQL container', async t => { - const app = await createLocalApp(fetch, undefined, undefined, 'selected-external-sql'); + const app = await createLocalApp(unexpected, profile, 'selected-external-sql'); t.after(() => app.close()); assert.equal((await app.inject({ url: '/local/workspace', headers })).json().container, 'selected-external-sql'); }); test('local sessions reject remote hosts, cross-origin requests, unknown users and fabricated bearer tokens', async t => { - const app = await createLocalApp(); + const app = await createLocalApp(unexpected, profile); t.after(() => app.close()); for (const untrusted of [ { host: 'evil.example', origin: localAppOrigin }, @@ -310,55 +227,57 @@ test('local sessions reject remote hosts, cross-origin requests, unknown users a } assert.equal((await app.inject({ url: '/local/session', method: 'POST', headers, payload: { user: 'invented-user' } })).statusCode, 400); - assert.equal((await app.inject({ url: '/auth/me', headers: { ...headers, authorization: 'Bearer alice' } })).statusCode, 401); - assert.equal((await app.inject({ url: '/api/Todo', method: 'POST', headers: { host: headers.host, origin: 'https://evil.example' }, - payload: { title: 'forbidden' } })).statusCode, 403); + assert.equal((await app.inject({ url: '/auth/me', headers: { ...headers, authorization: bearer('fabricated') } })).statusCode, 401); + assert.equal((await app.inject({ url: '/api/AppReady', method: 'POST', headers: { ...headers, origin: 'https://evil.example' }, + payload: {} })).statusCode, 403); }); -test('local gateway derives SQL identity from issued sessions and strips untrusted identity headers', async t => { +test('local gateway derives SQL identity from issued sessions and replaces untrusted identity headers', async t => { + let upstream = 0; let upstreamHeaders = new Headers(); const app = await createLocalApp(async (_url, options) => { + upstream++; upstreamHeaders = new Headers(options?.headers); return new Response('{"value":[]}'); - }); + }, profile); t.after(() => app.close()); const first = await app.inject({ url: '/local/session', method: 'POST', headers, payload: { user: developmentUsers[0]!.id } }); - assert.equal(first.statusCode, 200); const token: string = first.json().token; assert.equal(token.length, 43); assert.equal(first.headers['cache-control'], 'no-store'); - const authenticated = { ...headers, authorization: `Bearer ${token}`, 'x-ms-client-principal': 'forged', 'x-ms-api-role': 'admin' }; - assert.equal((await app.inject({ url: '/api/Todo', method: 'POST', - headers: { ...authenticated, origin: 'https://evil.example' }, payload: { title: 'forbidden' } })).statusCode, 403); + const authenticated = { ...headers, authorization: bearer(token), 'x-ms-client-principal': 'forged', 'x-ms-api-role': 'admin' }; assert.equal((await app.inject({ url: '/auth/me', headers: { ...authenticated, host: 'evil.example' } })).statusCode, 403); assert.equal((await app.inject({ url: '/auth/me', headers: authenticated })).json().oid, developmentUsers[0]!.id); - assert.equal((await app.inject({ url: '/api/Todo', headers: authenticated })).statusCode, 200); - const principal = JSON.parse(Buffer.from(upstreamHeaders.get('x-ms-client-principal')!, 'base64').toString()); - assert.equal(principal.claims.find((claim: { typ: string }) => claim.typ === 'oid').val, developmentUsers[0]!.id); + assert.equal((await app.inject({ url: '/api/AppReady', headers: authenticated })).statusCode, 200); + assert.equal(oidOf(upstreamHeaders), developmentUsers[0]!.id); assert.equal(upstreamHeaders.get('authorization'), null); - assert.equal(upstreamHeaders.get('x-ms-api-role'), null); + assert.equal(upstreamHeaders.get('x-ms-api-role'), 'AppUser'); const second = await app.inject({ url: '/local/session', method: 'POST', headers: authenticated, payload: { user: developmentUsers[1]!.id } }); assert.equal(second.statusCode, 200); assert.equal((await app.inject({ url: '/auth/me', headers: authenticated })).statusCode, 401); - const bob = { ...headers, authorization: `Bearer ${second.json().token}` }; - assert.equal((await app.inject({ url: '/auth/me', headers: bob })).json().oid, developmentUsers[1]!.id); + const bob = { ...headers, authorization: bearer(second.json().token) }; + const bobMe = (await app.inject({ url: '/auth/me', headers: bob })).json(); + assert.equal(bobMe.oid, developmentUsers[1]!.id); + assert.deepEqual(bobMe.access, { allowed: false, requiredRole: 'AppUser' }); + assert.equal((await app.inject({ url: '/api/AppReady', headers: bob })).statusCode, 403); + assert.equal(upstream, 1); assert.equal((await app.inject({ url: '/local/session', method: 'DELETE', headers: bob })).statusCode, 204); assert.equal((await app.inject({ url: '/auth/me', headers: bob })).statusCode, 401); }); -test('local unavailable services return explicit failures rather than simulated success', async t => { - const app = await createLocalApp(); +test('removed foundation routes are not served locally', async t => { + const app = await createLocalApp(unexpected, profile); t.after(() => app.close()); - const session = await app.inject({ url: '/local/session', method: 'POST', headers, payload: { user: developmentUsers[0]!.id } }); - const authenticated = { ...headers, authorization: `Bearer ${session.json().token}` }; - assert.equal((await app.inject({ url: '/storage/file', headers: authenticated })).statusCode, 503); - assert.equal((await app.inject({ url: '/functions/echo', method: 'POST', headers: authenticated, payload: {} })).statusCode, 503); + const authenticated = { ...headers, authorization: bearer(await session(app, developmentUsers[0]!.id)) }; + assert.equal((await app.inject({ url: '/storage/file', headers: authenticated })).statusCode, 404); + assert.equal((await app.inject({ url: '/jobs', headers: authenticated })).statusCode, 404); + assert.equal((await app.inject({ url: '/functions/echo', method: 'POST', headers: authenticated, payload: {} })).statusCode, 404); }); test('local app cannot start in a production environment', async () => { const previous = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; - try { await assert.rejects(createLocalApp(), /disabled in production/); } + try { await assert.rejects(createLocalApp(unexpected, profile), /disabled in production/); } finally { if (previous === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = previous; @@ -369,13 +288,13 @@ test('local preview sessions issue opaque tokens that expire exactly after eight const started = 1_000; let now = started; t.mock.method(Date, 'now', () => now); - const app = await createLocalApp(); + const app = await createLocalApp(unexpected, profile); t.after(() => app.close()); - const session = await app.inject({ method: 'POST', url: '/local/session', headers, payload: { preview: true } }); - assert.equal(session.statusCode, 200); - const token = z.object({ token: z.string().regex(/^[A-Za-z0-9_-]{43}$/) }).parse(session.json()).token; - assert.equal(session.headers['cache-control'], 'no-store'); - const authenticated = { ...headers, authorization: `Bearer ${token}` }; + const response = await app.inject({ method: 'POST', url: '/local/session', headers, payload: { preview: true } }); + assert.equal(response.statusCode, 200); + const token = z.object({ token: z.string().regex(/^[A-Za-z0-9_-]{43}$/) }).parse(response.json()).token; + assert.equal(response.headers['cache-control'], 'no-store'); + const authenticated = { ...headers, authorization: bearer(token) }; now = started + 8 * 60 * 60 * 1_000 - 1; const active = await app.inject({ url: '/auth/me', headers: authenticated }); assert.equal(active.statusCode, 200); @@ -385,7 +304,7 @@ test('local preview sessions issue opaque tokens that expire exactly after eight }); test('local preview session selection is a strict union and requires the exact local origin', async t => { - const app = await createLocalApp(); + const app = await createLocalApp(unexpected, profile); t.after(() => app.close()); assert.equal((await app.inject({ method: 'POST', url: '/local/session', headers: { ...headers, origin: 'https://evil.example' }, payload: { preview: true } })).statusCode, 403); @@ -416,19 +335,16 @@ test('local Entra verifies signed identity and role without accepting preview se upstream++; forwarded = new Headers(options?.headers); return Response.json({ value: [] }); - }, undefined, { profile: 'role-based-data', requiredRole: 'AppUser', readinessPath: '/api/AppReady' }, - runtimeFor().defaultSql, authentication, async () => keys.publicKey); + }, profile, runtimeFor().defaultSql, authentication, async () => keys.publicKey); t.after(() => app.close()); const config = await app.inject({ url: '/auth/config', headers }); assert.equal(config.statusCode, 200); assert.equal(config.headers['cache-control'], 'no-store'); assert.deepEqual(config.json(), { mode: 'entra', tenantId, clientId, scope: `api://${clientId}/access_as_user`, - localBackend: true, capabilities: { files: false, functions: false } }); - assert.equal('users' in config.json(), false); + localBackend: true, readinessPath: '/api/AppReady' }); const workspace = await app.inject({ url: '/local/workspace', headers }); - assert.equal(workspace.statusCode, 200); assert.deepEqual(workspace.json().authentication, authentication); - const allowed = { ...headers, authorization: `Bearer ${await signed(['AppUser'])}`, + const allowed = { ...headers, authorization: bearer(await signed(['AppUser'])), 'x-ms-api-role': 'admin', 'x-ms-client-principal': 'forged' }; const me = await app.inject({ url: '/auth/me', headers: allowed }); assert.equal(me.statusCode, 200); @@ -437,15 +353,14 @@ test('local Entra verifies signed identity and role without accepting preview se assert.equal(upstream, 1); assert.equal(forwarded.get('authorization'), null); assert.equal(forwarded.get('x-ms-api-role'), 'AppUser'); - const principal = JSON.parse(Buffer.from(forwarded.get('x-ms-client-principal')!, 'base64').toString()); - assert.equal(principal.claims.find((claim: { typ: string }) => claim.typ === 'oid').val, oid); - const denied = { ...headers, authorization: `Bearer ${await signed([])}`, 'x-ms-api-role': 'AppUser' }; + assert.equal(oidOf(forwarded), oid); + const denied = { ...headers, authorization: bearer(await signed([])), 'x-ms-api-role': 'AppUser' }; const deniedMe = await app.inject({ url: '/auth/me', headers: denied }); assert.equal(deniedMe.statusCode, 200); assert.deepEqual(deniedMe.json().access, { allowed: false, requiredRole: 'AppUser' }); assert.equal((await app.inject({ url: '/api/AppReady', headers: denied })).statusCode, 403); for (const token of ['invented-preview-token', await signed(['AppUser'], 'dddddddd-dddd-4ddd-8ddd-dddddddddddd')]) { - const invalid = { ...headers, authorization: `Bearer ${token}` }; + const invalid = { ...headers, authorization: bearer(token) }; assert.equal((await app.inject({ url: '/auth/me', headers: invalid })).statusCode, 401); assert.equal((await app.inject({ url: '/api/AppReady', headers: invalid })).statusCode, 401); } @@ -462,16 +377,12 @@ test('local Entra readiness remains an independent bounded synthetic dependency const app = await createLocalApp(async (url, options) => { probes++; assert.equal(new URL(String(url)).pathname, '/api/AppReady'); - assert.equal(options?.redirect, 'error'); - assert.ok(options?.signal); const forwarded = new Headers(options?.headers); assert.equal(forwarded.get('authorization'), null); assert.equal(forwarded.get('x-ms-api-role'), 'AppUser'); - const principal = JSON.parse(Buffer.from(forwarded.get('x-ms-client-principal')!, 'base64').toString()); - assert.equal(principal.claims.find((claim: { typ: string }) => claim.typ === 'oid').val, developmentUsers[0]!.id); + assert.equal(oidOf(forwarded), developmentUsers[0]!.id); return Response.json({ value: [] }); - }, undefined, { profile: 'role-based-data', requiredRole: 'AppUser', readinessPath: '/api/AppReady' }, - runtimeFor().defaultSql, authentication); + }, profile, runtimeFor().defaultSql, authentication); t.after(() => app.close()); assert.equal((await app.inject({ url: '/health/ready', headers })).statusCode, 200); assert.equal(probes, 1); @@ -480,7 +391,7 @@ test('local Entra readiness remains an independent bounded synthetic dependency }); test('local preview serves developer identity testing separately from the product', async t => { - const app = await createLocalApp(); + const app = await createLocalApp(unexpected, profile); t.after(() => app.close()); const product = await app.inject({ url: '/', headers }); assert.equal(product.statusCode, 200); @@ -505,7 +416,7 @@ test('local preview serves developer identity testing separately from the produc }); test('local Entra sign-in does not expose developer identity simulation', async t => { - const app = await createLocalApp(fetch, undefined, undefined, runtimeFor().defaultSql, { + const app = await createLocalApp(unexpected, profile, runtimeFor().defaultSql, { mode: 'entra', tenantId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', clientId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', }); @@ -513,4 +424,4 @@ test('local Entra sign-in does not expose developer identity simulation', async for (const url of ['/__dev/identity', '/__dev/identity.js', '/local/users']) { assert.equal((await app.inject({ url, headers })).statusCode, 404, url); } -}); +}); \ No newline at end of file diff --git a/tests/local-consent.test.mjs b/tests/local-consent.test.mjs index 8ac555e..364d145 100644 --- a/tests/local-consent.test.mjs +++ b/tests/local-consent.test.mjs @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, writeFile, readFile, readdir, rm, symlink, copyFile, utimes } from 'node:fs/promises'; +import { mkdtemp, mkdir, writeFile, readFile, readdir, rm, symlink, copyFile, utimes, realpath } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { execFile, spawn } from 'node:child_process'; @@ -28,21 +29,12 @@ async function fixture(t, selected = false) { 'dab/dab-config.json': '{}', 'src/local.ts': '// SQL operations', 'src/local-cli.ts': '// dispatch', - 'src/local-services.ts': '// services', - 'src/local-settings.ts': '// defaults', - 'src/local-maintenance.ts': '// maintenance', - 'src/local-services-smoke.ts': '// restart test', 'src/local-app-smoke.ts': '// browser/API test', - 'src/local-job-data.ts': '// fixtures', 'src/local-app.ts': '// gateway', 'src/selected-local.ts': '// selected app', 'src/artifacts.ts': '// pinned images', 'scripts/build-provenance.mjs': '// build', 'tsconfig.runtime.json': '{}', - 'functions/local.Dockerfile': 'FROM fixture', - 'functions/local-package.json': '{}', - 'functions/local-host.json': '{}', - 'functions/src/local-index.ts': '// timer', 'role-based-data.json': '{"profile":"role-based-data","requiredRole":"AppUser","readinessPath":"/api/Ready"}', 'src/role-based-profile.ts': '// role procedure grants', 'examples/todo/demo/smoke.mjs': '// selected acceptance', @@ -67,7 +59,7 @@ test('composite startup lists every missing scope before any mutation', async t const home = await fixture(t, true); const plan = await planConsent(home, { operation: 'role-based-app' }); assert.equal(plan.allowed, false); - assert.deepEqual(plan.requirements.map(item => item.scope), ['restore', 'build', 'sql-start', 'schema', 'services']); + assert.deepEqual(plan.requirements.map(item => item.scope), ['restore', 'build', 'sql-start', 'schema', 'services', 'synthetic']); await assert.rejects(assertConsent(home, { operation: 'role-based-app' }), /Missing local permission/); await assert.rejects(readFile(join(home, '.sql-apps', 'consent.json')), { code: 'ENOENT' }); }); @@ -81,7 +73,7 @@ test('unknown operations and missing workspace fail explicitly', async t => { const configless = await fixture(t, true); await rm(join(configless, 'role-based-data.json')); assert.deepEqual((await planConsent(configless, { operation: 'role-based-app' })).requirements.map(item => item.scope), - ['restore', 'build', 'sql-start', 'schema', 'services'], + ['restore', 'build', 'sql-start', 'schema', 'services', 'synthetic'], 'permission planning must not require domain configuration that launch validates separately'); }); @@ -194,6 +186,34 @@ for (const removed of [true, false]) { }); } +test('owner-file EPERM while another owner releases the lock is retried, not fatal', async t => { + const home = await fixture(t); + const lock = join(home, '.sql-apps', 'consent.lock'); + const token = '2a0243a1-32b2-4ecf-ae3d-ef2dab7e5509'; + const owner = join(lock, `${token}.json`); + await mkdir(lock, { recursive: true }); + await writeFile(owner, JSON.stringify({ pid: process.pid, token })); + const original = fs.readFile; + const denied = Object.assign(new Error('Synthetic Windows delete-pending read failure'), { code: 'EPERM' }); + let injected = false; + t.mock.method(fs, 'readFile', async (...args) => { + if (args[0] === owner && !injected) { + injected = true; + await rm(lock, { recursive: true, force: true }); + throw denied; + } + return original(...args); + }); + syncBuiltinESMExports(); + try { + assert.equal(await withFileLock(lock, async () => 'acquired', 2), 'acquired'); + assert.equal(injected, true); + } finally { + t.mock.restoreAll(); + syncBuiltinESMExports(); + } +}); + test('a live consent lock is protected and a killed owner is recovered immediately', { timeout: 5_000 }, async t => { const home = await fixture(t); await grantConsent(home, await planConsent(home, { operation: 'restore' })); @@ -296,12 +316,12 @@ test('concurrent grants preserve both scopes and repeated grants are deduplicate assert.equal((await planConsent(home, { operation: 'restore' })).allowed, true); }); -test('destructive recovery and maintenance grants are one-operation only', async t => { +test('destructive SQL recovery grants are one-operation only', async t => { const home = await fixture(t, true); const document = join(home, 'terms.txt'); await writeFile(document, 'Fixture terms'); await acknowledgeTerms(home, document); - for (const operation of ['recover-sql', 'maintain']) { + for (const operation of ['recover-sql']) { const plan = await planConsent(home, { operation }); assert.ok(plan.requirements.every(item => !item.reusable)); await assert.rejects(grantConsent(home, plan), /one-operation/); @@ -371,9 +391,11 @@ test('guarded execution denies before the runner and passes only approved operat await assert.rejects(main(['run', 'azure'], home, runner), /Unsupported/); }); -test('direct local CLI denies before workspace mutation', async t => { +// The cross-OS setup-diagnostics CI job runs without a build; npm test builds first and covers this. +const localCli = resolve('dist/src/local-cli.js'); +test('direct local CLI denies before workspace mutation', { skip: !existsSync(localCli) && 'requires npm run build' }, async t => { const home = await fixture(t); - const local = resolve('dist/src/local-cli.js'); + const local = localCli; await assert.rejects(execute(process.execPath, [local, 'workspace-init', '30000'], { cwd: home }), /Missing local permission/); await assert.rejects(readFile(join(home, '.sql-apps', 'workspace.json')), { code: 'ENOENT' }); const plan = await planConsent(home, { operation: 'workspace-init', selection: '30000' }); @@ -382,19 +404,16 @@ test('direct local CLI denies before workspace mutation', async t => { assert.equal(JSON.parse(await readFile(join(home, '.sql-apps', 'workspace.json'))).basePort, 30000); }); -test('startup planning includes implicit SQL fixtures and background retention effects', async t => { +test('startup planning includes the implicit SQL acceptance check; data restarts only DAB', async t => { const home = await fixture(t, true); - for (const operation of ['app', 'init', 'data']) { + for (const operation of ['app', 'role-based-app', 'init']) { const plan = await planConsent(home, { operation }); assert.ok(plan.requirements.some(item => item.scope === 'synthetic'), operation); } - const service = await planConsent(home, { operation: 'services' }); - assert.match(service.requirements[0].effects.join(' '), /retention/); - await grantConsent(home, service); - const directory = join(home, '.sql-apps', 'workspaces', JSON.parse(await readFile(join(home, '.sql-apps', 'workspace.json'))).id); - await mkdir(directory, { recursive: true }); - await writeFile(join(directory, 'local-settings.json'), '{"retentionDays":7}'); - assert.equal((await planConsent(home, { operation: 'services' })).allowed, false); + assert.deepEqual((await planConsent(home, { operation: 'data' })).requirements.map(item => item.scope), ['services']); + for (const removed of ['services', 'maintain', 'serve-sql', 'stop-services', 'services-test', 'api-test']) { + await assert.rejects(planConsent(home, { operation: removed }), /Unsupported consent operation/, removed); + } }); test('SQL startup grant is shared across matching profiles, not container targets', async t => { @@ -409,13 +428,12 @@ test('every runtime command declares its required scopes', async t => { const commands = { app: ['restore', 'build', 'sql-start', 'schema', 'services', 'synthetic'], init: ['restore', 'build', 'schema', 'synthetic'], - data: ['services', 'synthetic'], - serve: ['services'], 'serve-sql': ['services'], services: ['services'], - 'role-based-app': ['restore', 'build', 'sql-start', 'schema', 'services'], + data: ['services'], + serve: ['services'], + 'role-based-app': ['restore', 'build', 'sql-start', 'schema', 'services', 'synthetic'], 'role-based-serve': ['services'], 'start-sql': ['sql-start'], - test: ['synthetic'], 'api-test': ['synthetic'], 'app-test': ['synthetic'], - 'services-test': ['services', 'synthetic'], - stop: ['stop'], 'stop-services': ['stop'], maintain: ['maintenance'], 'recover-sql': ['sql-start'], + test: ['synthetic'], 'app-test': ['synthetic'], + stop: ['stop'], 'recover-sql': ['sql-start'], }; for (const [operation, expected] of Object.entries(commands)) { const plan = await planConsent(home, { operation }); @@ -434,7 +452,7 @@ test('application content edits keep grants; launcher and worker changes invalid await writeFile(join(home, 'role-based-data.json'), '{"profile":"role-based-data","requiredRole":"Volunteer","readinessPath":"/api/Ready"}'); assert.deepEqual((await planConsent(home, { operation: 'app' })).missing, []); assert.deepEqual((await planConsent(home, { operation: 'role-based-app' })).missing, []); - await writeFile(join(home, 'functions', 'src', 'local-index.ts'), '// changed worker effects'); + await writeFile(join(home, 'src', 'local-app.ts'), '// changed gateway'); assert.deepEqual((await planConsent(home, { operation: 'app' })).missing.map(item => item.scope), ['services']); await writeFile(join(home, 'src', 'role-based-profile.ts'), '// changed grant code'); assert.deepEqual((await planConsent(home, { operation: 'role-based-app' })).missing.map(item => item.scope), ['schema', 'services']); @@ -444,8 +462,8 @@ test('one bundle approval covers workspace, startup scopes and inline SQL terms' const { planBundle, grantBundle, SQL_TERMS } = await import('../src/local-consent.mjs'); const home = await fixture(t); const bundle = await planBundle(home, { profile: 'role-based-data', workspace: '30000' }); - assert.equal(bundle.operation, 'role-based-app'); - assert.deepEqual(bundle.requirements.map(item => item.scope), ['workspace', 'restore', 'build', 'sql-start', 'schema', 'services']); + assert.equal(bundle.operation, 'app'); + assert.deepEqual(bundle.requirements.map(item => item.scope), ['workspace', 'restore', 'build', 'sql-start', 'schema', 'services', 'synthetic']); assert.equal(bundle.terms.required, true); assert.equal(bundle.terms.url, SQL_TERMS.url); assert.match(bundle.terms.statement, /ACCEPT_EULA=Y/); @@ -473,18 +491,22 @@ test('bundle planning validates profile and workspace and reuses an existing sel const { planBundle } = await import('../src/local-consent.mjs'); const home = await fixture(t); await assert.rejects(planBundle(home, { profile: 'azure' }), /profile/); - await assert.rejects(planBundle(home, { profile: 'foundation' }), /workspace/); - await assert.rejects(planBundle(home, { profile: 'foundation', workspace: '30001' }), /port block/); + await assert.rejects(planBundle(home, { profile: 'foundation' }), /Select bundle profile role-based-data/); + await assert.rejects(planBundle(home, { profile: 'role-based-data' }), /workspace/); + await assert.rejects(planBundle(home, { profile: 'role-based-data', workspace: '30001' }), /port block/); const selected = await fixture(t, true); - const bundle = await planBundle(selected, { profile: 'foundation' }); + const bundle = await planBundle(selected, { profile: 'role-based-data' }); assert.equal(bundle.operation, 'app'); assert.deepEqual(bundle.requirements.map(item => item.scope), ['restore', 'build', 'sql-start', 'schema', 'services', 'synthetic']); - await assert.rejects(planBundle(selected, { profile: 'foundation', workspace: '30010' }), /differs/); + await assert.rejects(planBundle(selected, { profile: 'role-based-data', workspace: '30010' }), /differs/); }); test('concurrent dispatch consumes a one-operation grant only once, including failed execution', async t => { const home = await fixture(t, true); - const request = { operation: 'maintain' }; + const document = join(home, 'terms.txt'); + await writeFile(document, 'Fixture terms'); + await acknowledgeTerms(home, document); + const request = { operation: 'recover-sql' }; await grantConsent(home, await planConsent(home, request), { once: true }); const results = await Promise.allSettled([assertConsent(home, request), assertConsent(home, request)]); assert.equal(results.filter(item => item.status === 'fulfilled').length, 1); @@ -502,7 +524,7 @@ test('selected commands bind artifact content and reject another workspace befor const workspace = JSON.parse(await readFile(join(home, '.sql-apps', 'workspace.json'))); const directory = join(home, '.sql-apps', 'workspaces', workspace.id, 'applications', 'todo', 'local-simulation', 'artifact'); await mkdir(directory, { recursive: true }); - const manifest = { home, workspaceId: workspace.id, profile: 'local-simulation', name: 'todo' }; + const manifest = { home: await realpath(home), workspaceId: workspace.id, profile: 'local-simulation', name: 'todo' }; await writeFile(join(directory, 'application-build.json'), JSON.stringify(manifest)); for (const folder of ['sql', 'setup', 'server', 'dab', 'public']) await mkdir(join(directory, folder)); await writeFile(join(directory, 'sql', 'schema.sql'), '-- fixture schema'); diff --git a/tests/local-job-snapshots.test.ts b/tests/local-job-snapshots.test.ts deleted file mode 100644 index e5affcd..0000000 --- a/tests/local-job-snapshots.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { test, type TestContext } from 'node:test'; -import assert from 'node:assert/strict'; -import { ContainerClient, type BlobDeleteOptions } from '@azure/storage-blob'; -import { deleteLocalJobSnapshot, withJobSnapshotLease } from '../src/local-job-snapshots.js'; -import type { FileJob } from '../src/file-jobs.js'; - -function fixture(t: TestContext) { - const tenant = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; - const oid = '22222222-2222-4222-8222-222222222222'; - const id = '11111111-1111-4111-8111-111111111111'; - let row: FileJob | undefined = { - id, filename: 'sample.txt', status: 'failed', sha256: null, byte_count: null, - line_count: null, error: 'recovery timeout', - }; - const files = new ContainerClient('https://storage.invalid/files'); - const blob = files.getBlockBlobClient(`${tenant}/${oid}/jobs/${id}`); - let held: string | undefined; - let exists = true; - let deleteFailure: Error | undefined; - let renewalFailure: Error | undefined; - const calls: string[] = []; - const missing = Object.assign(new Error('Snapshot absent'), { code: 'BlobNotFound', statusCode: 404 }); - t.mock.method(files, 'getBlockBlobClient', (key: string) => { - assert.equal(key, `${tenant}/${oid}/jobs/${id}`); - return blob; - }); - const getLease = blob.getBlobLeaseClient.bind(blob); - t.mock.method(blob, 'getBlobLeaseClient', () => { - const lease = getLease(); - t.mock.method(lease, 'acquireLease', async (duration: number) => { - assert.equal(duration, 60); - if (!exists) throw missing; - if (held) throw Object.assign(new Error('Worker owns snapshot'), { code: 'LeaseAlreadyPresent', statusCode: 409 }); - held = lease.leaseId; - calls.push('acquire'); - return { leaseId: held }; - }); - t.mock.method(lease, 'renewLease', async () => { - if (renewalFailure) throw renewalFailure; - assert.equal(held, lease.leaseId); - calls.push('renew'); - return { leaseId: held }; - }); - t.mock.method(lease, 'releaseLease', async () => { - calls.push('release'); - if (!exists) throw missing; - assert.equal(held, lease.leaseId); - held = undefined; - return {}; - }); - return lease; - }); - t.mock.method(blob, 'deleteIfExists', async (options?: BlobDeleteOptions) => { - assert.equal(options?.conditions?.leaseId, held); - if (deleteFailure) throw deleteFailure; - calls.push('blob-delete'); - exists = false; - held = undefined; - return { succeeded: true }; - }); - const jobs = { - get: async (owner: string, job: string) => { - assert.equal(owner, oid); assert.equal(job, id); - return row; - }, - delete: async () => { calls.push('sql-delete'); row = undefined; }, - }; - return { - files, blob, jobs, calls, - remove: () => deleteLocalJobSnapshot(jobs, files, tenant, oid, id), - snapshotExists: () => exists, - setDeleteFailure: (error?: Error) => { deleteFailure = error; }, - setRenewalFailure: (error?: Error) => { renewalFailure = error; }, - row: () => row, - held: () => held, - }; -} - -test('a timed-out worker retains exclusive snapshot protection until it finishes', async t => { - const state = fixture(t); - await withJobSnapshotLease(state.blob, async () => { - await assert.rejects(state.remove(), { statusCode: 409 }); - assert.ok(state.row()); - assert.equal(state.snapshotExists(), true); - assert.equal(state.calls.includes('sql-delete'), false); - }); - assert.equal(state.held(), undefined); - assert.equal(await state.remove(), true); - assert.deepEqual(state.calls, ['acquire', 'release', 'acquire', 'sql-delete', 'blob-delete', 'release']); -}); - -test('a live worker renews its finite snapshot lease', async t => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const state = fixture(t); - let finish!: () => void; - const work = withJobSnapshotLease(state.blob, () => new Promise(resolve => { finish = resolve; })); - await Promise.resolve(); - t.mock.timers.tick(30_000); - await Promise.resolve(); - assert.deepEqual(state.calls, ['acquire', 'renew']); - finish(); - await work; - assert.deepEqual(state.calls, ['acquire', 'renew', 'release']); -}); - -test('lease renewal failure aborts protected work before exclusivity expires', async t => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const state = fixture(t); - const failure = new Error('Lease renewal failed'); - state.setRenewalFailure(failure); - const work = withJobSnapshotLease(state.blob, async (signal?: AbortSignal) => { - assert.ok(signal, 'protected work receives a lease-loss signal'); - await new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => reject(signal.reason), { once: true }); - }); - }); - await Promise.resolve(); - t.mock.timers.tick(30_000); - await assert.rejects(work, error => error === failure); - assert.equal(state.held(), undefined); -}); - -test('snapshot lease releases after a worker error and preserves its error', async t => { - const state = fixture(t); - const failure = new Error('Worker failed'); - await assert.rejects(withJobSnapshotLease(state.blob, async () => { throw failure; }), error => error === failure); - assert.equal(state.held(), undefined); - assert.equal(await state.remove(), true); -}); - -test('SQL deletion failure releases the lease without deleting the snapshot', async t => { - const state = fixture(t); - const failure = new Error('SQL unavailable'); - t.mock.method(state.jobs, 'delete', async () => { throw failure; }); - await assert.rejects(state.remove(), error => error === failure); - assert.ok(state.row()); - assert.equal(state.snapshotExists(), true); - assert.equal(state.held(), undefined); -}); - -test('failed leased snapshot cleanup remains retryable after SQL row removal', async t => { - const state = fixture(t); - const failure = new Error('Storage unavailable'); - state.setDeleteFailure(failure); - await assert.rejects(state.remove(), error => error === failure); - assert.equal(state.row(), undefined); - assert.equal(state.snapshotExists(), true); - assert.equal(state.held(), undefined); - state.setDeleteFailure(); - assert.equal(await state.remove(), false); - assert.equal(state.snapshotExists(), false); - assert.equal(await state.remove(), false); -}); diff --git a/tests/local-reliability.test.ts b/tests/local-reliability.test.ts deleted file mode 100644 index a8231cd..0000000 --- a/tests/local-reliability.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { ContainerClient } from '@azure/storage-blob'; -import { localSettingsSchema } from '../src/local-settings.js'; -import { localTenantId, maintainLocalJobs, maintenanceAction, sqlTime } from '../src/local-maintenance.js'; -import { watchJobProgress } from '../src/web/job-progress.js'; -import { fileJobSchema, jobMessageSchema } from '../src/file-jobs.js'; -import { localJobData } from '../src/local-job-data.js'; - -test('local feature settings have safe defaults and reject typos or unbounded timers/retention', () => { - assert.equal(localSettingsSchema.parse({}).retentionDays, 0); - assert.equal(localSettingsSchema.parse({}).staleJobMinutes, 60); - for (const value of [ - { retentionDays: -1 }, { retentionDays: 366 }, { progressIntervalMs: 999 }, - { staleJobMinutes: 4 }, { tracing: 'true' }, { unknownFlag: true }, - ]) assert.equal(localSettingsSchema.safeParse(value).success, false); -}); - -test('reconciliation and retention use exact UTC thresholds and never expire active jobs', () => { - const now = Date.parse('2026-10-08T12:00:00Z'); - const settings = localSettingsSchema.parse({}); - const row = fileJobSchema.parse({ id: '11111111-1111-4111-8111-111111111111', filename: 'sample.txt', - status: 'queued', sha256: null, byte_count: null, line_count: null, error: null, updated_at: '2026-10-08T11:00:00' }); - assert.equal(maintenanceAction(row, settings, now - 1), undefined); - assert.equal(maintenanceAction(row, settings, now), 'recover'); - assert.equal(maintenanceAction({ ...row, status: 'completed' }, settings, now + 100 * 86400_000), undefined); - const enabled = { ...settings, retentionDays: 1 }; - assert.equal(maintenanceAction({ ...row, status: 'completed' }, enabled, sqlTime(row.updated_at) + 86400_000 - 1), undefined); - assert.equal(maintenanceAction({ ...row, status: 'failed' }, enabled, sqlTime(row.updated_at) + 86400_000), 'remove'); - assert.equal(maintenanceAction({ ...row, status: 'processing' }, enabled, now + 86400_000), 'recover'); - assert.throws(() => sqlTime(undefined), /missing/); - assert.throws(() => sqlTime('invalid'), /Invalid/); -}); - -for (const sqlFails of [true, false]) { - test(`terminal retention ${sqlFails ? 'preserves the snapshot when SQL deletion fails' : 'deletes SQL before the snapshot'}`, async t => { - const oid = '22222222-2222-4222-8222-222222222222'; - const row = fileJobSchema.parse({ - id: '11111111-1111-4111-8111-111111111111', filename: 'sample.txt', - status: 'failed', sha256: null, byte_count: null, line_count: null, - error: 'worker failed', updated_at: '2026-10-08T11:00:00Z', - }); - const calls: string[] = []; - const failure = new Error('SQL unavailable'); - const jobs = localJobData('http://127.0.0.1:15000', async (_url, options) => { - if (options?.method === 'DELETE') { - calls.push('sql'); - if (sqlFails) throw failure; - return new Response(null, { status: 204 }); - } - - return Response.json({ value: [row] }); - }); - const files = new ContainerClient('https://storage.invalid/files'); - const traces = new ContainerClient('https://storage.invalid/traces'); - const key = `${localTenantId}/${oid}/jobs/${row.id}`; - const snapshot = files.getBlockBlobClient(key); - const lease = snapshot.getBlobLeaseClient(); - t.mock.method(snapshot, 'getBlobLeaseClient', () => lease); - t.mock.method(lease, 'acquireLease', async () => ({ leaseId: lease.leaseId })); - t.mock.method(lease, 'releaseLease', async () => ({})); - t.mock.method(files, 'getBlockBlobClient', (name: string) => { - assert.equal(name, key); - return snapshot; - }); - t.mock.method(snapshot, 'deleteIfExists', async () => { - calls.push('blob'); - return { succeeded: true }; - }); - t.mock.method(files, 'listBlobsFlat', async function* () {}); - t.mock.method(traces, 'listBlobsFlat', async function* () {}); - const action = () => maintainLocalJobs(jobs, files, traces, - localSettingsSchema.parse({ retentionDays: 1 }), Date.parse('2026-10-10T12:00:00Z'), [oid]); - if (sqlFails) { - await assert.rejects(action(), error => error === failure); - assert.deepEqual(calls, ['sql']); - } else { - const result = await action(); - assert.equal(result.removedJobs, 1); - assert.deepEqual(calls, ['sql', 'blob']); - } - }); -} - -test('retention cannot delete a timed-out job while its worker holds the snapshot lease', async t => { - const oid = '22222222-2222-4222-8222-222222222222'; - const row = fileJobSchema.parse({ - id: '11111111-1111-4111-8111-111111111111', filename: 'sample.txt', status: 'failed', - sha256: null, byte_count: null, line_count: null, error: 'recovery timeout', - updated_at: '2026-10-08T11:00:00Z', - }); - let sqlDeleted = false; - let snapshotDeleted = false; - const jobs = localJobData('http://127.0.0.1:15000', async (_url, options) => { - if (options?.method === 'DELETE') sqlDeleted = true; - return Response.json({ value: [row] }); - }); - const files = new ContainerClient('https://storage.invalid/files'); - const traces = new ContainerClient('https://storage.invalid/traces'); - const snapshot = files.getBlockBlobClient(`${localTenantId}/${oid}/jobs/${row.id}`); - const lease = snapshot.getBlobLeaseClient(); - const conflict = Object.assign(new Error('Snapshot is leased by a live worker'), { statusCode: 409, code: 'LeaseAlreadyPresent' }); - t.mock.method(files, 'getBlockBlobClient', () => snapshot); - t.mock.method(snapshot, 'getBlobLeaseClient', () => lease); - t.mock.method(lease, 'acquireLease', async () => { throw conflict; }); - t.mock.method(snapshot, 'deleteIfExists', async () => { snapshotDeleted = true; return { succeeded: true }; }); - t.mock.method(files, 'listBlobsFlat', async function* () {}); - t.mock.method(traces, 'listBlobsFlat', async function* () {}); - await assert.rejects(maintainLocalJobs(jobs, files, traces, localSettingsSchema.parse({ retentionDays: 1 }), - Date.parse('2026-10-10T12:00:00Z'), [oid]), error => error === conflict); - assert.equal(sqlDeleted, false); - assert.equal(snapshotDeleted, false); -}); - -test('stale recovery skips a job while its worker holds the snapshot lease', async t => { - const oid = '22222222-2222-4222-8222-222222222222'; - const row = fileJobSchema.parse({ - id: '11111111-1111-4111-8111-111111111111', filename: 'sample.txt', status: 'processing', - sha256: null, byte_count: null, line_count: null, error: null, updated_at: '2026-10-08T11:00:00Z', - }); - let updated = false; - const jobs = localJobData('http://127.0.0.1:15000', async (_url, options) => { - if (options?.method === 'PATCH') updated = true; - return Response.json({ value: [row] }); - }); - const files = new ContainerClient('https://storage.invalid/files'); - const traces = new ContainerClient('https://storage.invalid/traces'); - const snapshot = files.getBlockBlobClient(`${localTenantId}/${oid}/jobs/${row.id}`); - const lease = snapshot.getBlobLeaseClient(); - const conflict = Object.assign(new Error('Snapshot is leased by a live worker'), { - statusCode: 409, code: 'LeaseAlreadyPresent', - }); - t.mock.method(files, 'getBlockBlobClient', () => snapshot); - t.mock.method(snapshot, 'getBlobLeaseClient', () => lease); - t.mock.method(lease, 'acquireLease', async () => { throw conflict; }); - t.mock.method(files, 'listBlobsFlat', async function* () {}); - t.mock.method(traces, 'listBlobsFlat', async function* () {}); - const result = await maintainLocalJobs(jobs, files, traces, localSettingsSchema.parse({}), - Date.parse('2026-10-10T12:00:00Z'), [oid]); - assert.equal(updated, false); - assert.equal(result.recovered, 0); -}); - -test('progress polling never overlaps, pauses while unavailable, backs off and cancels', async t => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - let calls = 0; - let available = false; - let fail = false; - let release: (() => void) | undefined; - const reports: unknown[] = []; - const stop = watchJobProgress({ - intervalMs: 1000, available: () => available, - report: error => { reports.push(error); }, - refresh: async signal => { - calls++; assert.equal(signal.aborted, false); - if (fail) throw new Error('dependency unavailable'); - await new Promise(resolve => { release = resolve; }); - }, - }); - t.after(stop); - t.mock.timers.tick(1000); - assert.equal(calls, 0); - available = true; - t.mock.timers.tick(1000); - assert.equal(calls, 1); - t.mock.timers.tick(10000); - assert.equal(calls, 1); - release!(); - await new Promise(resolve => setImmediate(resolve)); - assert.deepEqual(reports, [undefined]); - fail = true; - t.mock.timers.tick(1000); - await new Promise(resolve => setImmediate(resolve)); - assert.equal(calls, 2); - t.mock.timers.tick(1999); - assert.equal(calls, 2); - t.mock.timers.tick(1); - await new Promise(resolve => setImmediate(resolve)); - assert.equal(calls, 3); - stop(); - t.mock.timers.tick(30000); - assert.equal(calls, 3); -}); - -test('queue trace context is validated and status updates always carry a fresh timestamp', async () => { - const job = { id: '11111111-1111-4111-8111-111111111111', oid: '22222222-2222-4222-8222-222222222222', - tenantId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', key: 'snapshot' }; - assert.equal(jobMessageSchema.safeParse({ ...job, trace: { traceId: 'a'.repeat(32), spanId: 'b'.repeat(16) } }).success, true); - assert.equal(jobMessageSchema.safeParse({ ...job, trace: { traceId: 'secret', spanId: 'token' } }).success, false); - const before = Date.now(); - const data = localJobData('http://127.0.0.1:15000', async (_url, options) => { - const body = JSON.parse(String(options?.body)); - assert.equal(body.status, 'failed'); - assert.ok(Date.parse(body.updated_at) >= before); - return new Response(null, { status: 204 }); - }); - await data.update(job.oid, job.id, { status: 'failed' }); -}); diff --git a/tests/local-services.test.ts b/tests/local-services.test.ts deleted file mode 100644 index ddc0f87..0000000 --- a/tests/local-services.test.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { test, type TestContext } from 'node:test'; -import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import { analyzeFile } from '../functions/src/file-analysis.js'; -import { ensureOwned, serviceNames, stopLocalServices, storageConnection, localServiceReadiness, - localFunctionsOrigin, localServiceImages } from '../src/local-services.js'; -import { deleteLocalJob, localJobData } from '../src/local-job-data.js'; -import type { FileJob } from '../src/file-jobs.js'; -import { jobMessageSchema } from '../src/file-jobs.js'; -import { localDabConfig, localPrincipal } from '../src/local.js'; -import { run, type Run } from '../src/process.js'; -import { createServer } from 'node:http'; -import { once } from 'node:events'; -import { readFile } from 'node:fs/promises'; -import { DependencyReadinessError } from '../src/readiness.js'; -import { localWorkspaceFixture } from './local-workspace-fixture.js'; - -const readinessInput = { - user: { oid: '11111111-1111-4111-8111-111111111111', tenantId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, - input: { readiness: true }, -}; - -async function readinessStorage(t: TestContext, failure?: 'blob' | 'queue' | 'stall' | 'stalled-blob-and-queue') { - const requests: { method: string; path: string }[] = []; - const server = createServer((request, response) => { - const url = new URL(request.url ?? '/', 'http://fixture.invalid'); - const method = request.method ?? ''; - requests.push({ method, path: url.pathname }); - const dependency = ['/fixturestorage/files', '/fixturestorage/traces'].includes(url.pathname) ? 'blob' : 'queue'; - if (['stall', 'stalled-blob-and-queue'].includes(failure ?? '') && dependency === 'blob') return; - response.writeHead(failure === dependency || failure === 'stalled-blob-and-queue' ? 503 : 200, { - 'x-ms-request-id': 'fixture-readiness', 'x-ms-version': '2025-11-05', - 'x-ms-approximate-messages-count': '0', etag: '"fixture"', - 'last-modified': 'Mon, 01 Jan 2024 00:00:00 GMT', - }); - response.end(); - }); - server.listen(0, '127.0.0.1'); - await once(server, 'listening'); - t.after(() => new Promise((done, reject) => { - server.closeAllConnections(); - server.close(error => error ? reject(error) : done()); - })); - const address = server.address(); - assert.ok(address && typeof address !== 'string'); - const endpoint = `http://127.0.0.1:${address.port}/fixturestorage`; - const connection = `DefaultEndpointsProtocol=http;AccountName=fixturestorage;` + - `AccountKey=${Buffer.alloc(32).toString('base64')};BlobEndpoint=${endpoint};QueueEndpoint=${endpoint};`; - return { requests, ready: localServiceReadiness(connection, 'synthetic-readiness-secret') }; -} - -for (const failure of [undefined, 'blob', 'queue', 'functions'] as const) { - test(`service readiness uses real read-only SDK operations: ${failure ?? 'healthy'}`, async t => { - const { requests, ready } = await readinessStorage(t, failure === 'functions' ? undefined : failure); - const signal = AbortSignal.timeout(4_000); - const check = ready(async (url, options) => { - assert.equal(String(url), `${localFunctionsOrigin}/api/echo`); - assert.equal(options?.signal, signal); - assert.equal(options?.redirect, 'error'); - assert.equal(new Headers(options?.headers).get('authorization'), 'Bearer synthetic-readiness-secret'); - assert.equal(options?.body, JSON.stringify(readinessInput)); - return failure === 'functions' ? new Response(null, { status: 503 }) : Response.json({ result: readinessInput }); - }, signal); - if (failure) await assert.rejects(check, error => - error instanceof DependencyReadinessError && error.dependency === failure); - else { - await check; - assert.equal(requests.length, 4); - assert.deepEqual(requests.map(request => request.path).sort(), [ - '/fixturestorage/file-jobs', '/fixturestorage/file-jobs-poison', '/fixturestorage/files', '/fixturestorage/traces', - ]); - } - assert.ok(requests.every(request => ['GET', 'HEAD'].includes(request.method))); - assert.equal(new Set(requests.map(request => `${request.method} ${request.path}`)).size, requests.length, - 'metadata SDK retries must not multiply requests within one readiness attempt'); - }); -} - -for (const scenario of [ - { name: 'refused connection', response: (): Response => { throw new Error('connection refused'); } }, - { name: 'wrong credential', response: () => new Response(null, { status: 401 }) }, - { name: 'absent function', response: () => new Response(null, { status: 404 }) }, - { name: 'empty success', response: () => new Response(null, { status: 204 }) }, - { name: 'invalid JSON', response: () => new Response('not-json') }, - { name: 'wrong echo', response: () => Response.json({ result: { user: readinessInput.user, input: {} } }) }, -]) { - test(`Functions readiness rejects ${scenario.name}`, async t => { - const { ready } = await readinessStorage(t); - await assert.rejects(ready(async () => scenario.response(), AbortSignal.timeout(4_000)), - error => error instanceof DependencyReadinessError && error.dependency === 'functions'); - }); -} - -test('stalled Blob metadata requests respect the shared abort deadline', async t => { - const { ready } = await readinessStorage(t, 'stall'); - const start = performance.now(); - await assert.rejects(ready(async () => Response.json({ result: readinessInput }), AbortSignal.timeout(100)), - error => error instanceof DependencyReadinessError && error.dependency === 'blob'); - assert.ok(performance.now() - start < 1_100, 'readiness must not wait for the normal storage retry budget'); -}); - -test('readiness reports failures in dependency order instead of completion order', async t => { - const { ready } = await readinessStorage(t, 'stalled-blob-and-queue'); - await assert.rejects(ready(async () => Response.json({ result: readinessInput }), AbortSignal.timeout(100)), - error => error instanceof DependencyReadinessError && error.dependency === 'blob'); -}); - -test('local Functions require the SQL Apps development guard and always reject production', async () => { - for (const env of [ - { NODE_ENV: 'development', SQL_APPS_LOCAL_FUNCTIONS: undefined }, - { NODE_ENV: 'production', SQL_APPS_LOCAL_FUNCTIONS: 'true' }, - ]) { - await assert.rejects(run(process.execPath, ['dist/functions/src/local-index.js'], { env }), - /Local Functions entry point requires explicit development mode outside production/); - } - await assert.rejects(run(process.execPath, ['dist/functions/src/local-index.js'], { - env: { NODE_ENV: 'development', SQL_APPS_LOCAL_FUNCTIONS: 'true', LOCAL_FUNCTION_SECRET: undefined, - AzureWebJobsStorage: undefined, LOCAL_DAB_URL: undefined }, - }), /Local Functions settings are incomplete/); -}); - -test('local Functions use stable Docker DNS for DAB instead of a disposable container address', async () => { - const source = await readFile('src/local-services.ts', 'utf8'); - assert.match(source, /LOCAL_DAB_URL:\s*`http:\/\/\$\{names\.data\}:5000`/); - assert.doesNotMatch(source, /const dataIp\s*=|LOCAL_DAB_URL:\s*`http:\/\/\$\{dataIp\}/); - const dataSource = await readFile('src/local.ts', 'utf8'); - assert.match(dataSource, /network', 'connect', servicesNetwork, dataContainer/); - const acceptance = await readFile('src/local-services-smoke.ts', 'utf8'); - assert.match(acceptance, /LOCAL_DAB_URL:\s*`http:\/\/\$\{names\.data\}:5000`/); -}); - -test('file analysis has exact size, hash and CRLF/LF/CR text-line results', () => { - for (const [text, count] of [['', 0], ['a', 1], ['a\n', 2], ['a\r\nb\rc\n', 4]] as const) { - const bytes = Buffer.from(text); - assert.deepEqual(analyzeFile(bytes), { sha256: createHash('sha256').update(bytes).digest('hex'), - byte_count: bytes.length, line_count: count }); - } - assert.equal(analyzeFile(Buffer.alloc(4 * 1024 * 1024)).byte_count, 4 * 1024 * 1024); - assert.throws(() => analyzeFile(Buffer.alloc(4 * 1024 * 1024 + 1)), /4 MiB/); -}); - -for (const selection of [undefined, 'legacy', 42000] as const) { - const mode = selection === undefined ? 'legacy-unselected' : selection === 'legacy' ? 'legacy' : 'isolated'; - test(`local service lifecycle preserves volumes and refuses unowned service names before writes (${mode})`, async t => { - const runtime = await localWorkspaceFixture(t, selection); - const names = serviceNames(runtime.defaultSql); - const images = localServiceImages(runtime.defaultSql); - assert.ok(typeof images.storage === 'string'); - const calls: string[][] = []; - let unowned: 'functions' | 'storage' | undefined; - const runner: Run = async (command, args) => { - assert.equal(command, 'docker'); - calls.push([...args]); - const name = [names.functions, names.storage].find(name => - args.includes(name) || args.includes(`name=^${name}$`)); - assert.ok(name, 'Only fixture service names may be inspected or modified'); - const role = name === names.functions ? 'functions' : 'storage'; - if (args[0] === 'ps') return name; - if (args[0] === 'inspect') { - if (args.includes('{{index .Config.Labels "sql-apps.local"}}')) return role === unowned ? 'not-owned' : role; - assert.ok(args.some(arg => arg.includes('HostConfig.PortBindings'))); - return JSON.stringify({ - workspace: runtime.id, - image: role === 'storage' ? images.storage : `${runtime.imageRepository}:${runtime.mode === 'isolated' ? 'a'.repeat(64) : 'local'}`, - ports: role === 'storage' ? { - '10000/tcp': [{ HostIp: '127.0.0.1', HostPort: String(runtime.ports.blob) }], - '10001/tcp': [{ HostIp: '127.0.0.1', HostPort: String(runtime.ports.queue) }], - } : { '80/tcp': [{ HostIp: '127.0.0.1', HostPort: String(runtime.ports.functions) }] }, - }); - } - if (args[0] === 'rm') assert.deepEqual(args, ['rm', '--force', names.functions]); - else if (args[0] === 'stop') assert.deepEqual(args, ['stop', names.storage]); - else assert.fail(`Unexpected fixture command: ${args.join(' ')}`); - return ''; - }; - for (const role of ['functions', 'storage'] as const) { - unowned = role; - calls.length = 0; - await assert.rejects(stopLocalServices(runtime.defaultSql, runner), /not project-owned/); - assert.equal(calls.some(args => ['rm', 'stop'].includes(args[0]!)), false); - } - unowned = undefined; - calls.length = 0; - await stopLocalServices(runtime.defaultSql, runner); - assert.deepEqual(calls.filter(args => ['rm', 'stop'].includes(args[0]!)), - [['rm', '--force', names.functions], ['stop', names.storage]]); - assert.equal(calls.some(args => args[0] === 'volume'), false); - assert.equal(await ensureOwned('service', 'storage', async () => ''), false); - assert.throws(() => serviceNames('bad;name'), /Invalid/); - assert.ok(storageConnection('key').includes(`BlobEndpoint=http://127.0.0.1:${runtime.ports.blob}/sqlappslocal;`)); - assert.ok(storageConnection('key').includes(`QueueEndpoint=http://127.0.0.1:${runtime.ports.queue}/sqlappslocal;`)); - assert.ok(storageConnection('key', names.storage).includes(`BlobEndpoint=http://${names.storage}:10000/sqlappslocal;`)); - assert.ok(storageConnection('key', names.storage).includes(`QueueEndpoint=http://${names.storage}:10001/sqlappslocal;`)); - }); -} - -test('job queue messages reject caller-managed or untrusted identity fields', () => { - const job = { id: '11111111-1111-4111-8111-111111111111', oid: '22222222-2222-4222-8222-222222222222', - tenantId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', key: 'snapshot' }; - assert.equal(jobMessageSchema.safeParse(job).success, true); - assert.equal(jobMessageSchema.safeParse({ ...job, status: 'completed' }).success, false); - assert.equal(jobMessageSchema.safeParse({ ...job, oid: 'not-a-guid' }).success, false); -}); - -test('only server processor role can create or mutate local jobs; production exposes no job entity', async () => { - const config = await localDabConfig() as { entities: { FileJob: { permissions: { role: string; actions: unknown[] }[] } } }; - assert.deepEqual(config.entities.FileJob.permissions.find(p => p.role === 'processor')?.actions, ['create', 'read', 'update', 'delete']); - const authenticated = config.entities.FileJob.permissions.find(p => p.role === 'authenticated')!; - assert.equal(authenticated.actions.length, 1); - assert.equal((authenticated.actions[0] as { action: string }).action, 'read'); - const claims = JSON.parse(Buffer.from(localPrincipal('alice'), 'base64').toString()).claims; - assert.equal(claims.find((c: { typ: string }) => c.typ === 'roles').val, 'authenticated'); -}); - -test('processor adapter uses explicit trusted role and fails closed on invalid cross-origin pagination', async () => { - const data = localJobData('http://127.0.0.1:15000', async (_url, options) => { - const headers = new Headers(options?.headers); - assert.equal(headers.get('x-ms-api-role'), 'processor'); - const claims = JSON.parse(Buffer.from(headers.get('x-ms-client-principal')!, 'base64').toString()).claims; - assert.equal(claims.find((c: { typ: string }) => c.typ === 'roles').val, 'processor'); - return Response.json({ value: [], nextLink: 'https://unexpected.invalid/api/FileJob' }); - }); - await assert.rejects(data.list('alice'), /Invalid job pagination/); - await assert.rejects(localJobData('http://127.0.0.1:15000', async () => new Response('', { status: 503 })).list('alice'), /503/); -}); - -test('browser job listing returns every active job and only one recent terminal page', async () => { - const requests: URL[] = []; - const active = { - id: '11111111-1111-4111-8111-111111111111', filename: 'active.txt', status: 'processing', - sha256: null, byte_count: null, line_count: null, error: null, - }; - const completed = { - id: '22222222-2222-4222-8222-222222222222', filename: 'done.txt', status: 'completed', - sha256: 'a'.repeat(64), byte_count: 1, line_count: 1, error: null, - }; - const older = { - ...completed, id: '33333333-3333-4333-8333-333333333333', filename: 'older.txt', - }; - const data = localJobData('http://127.0.0.1:15000', async input => { - const url = new URL(String(input)); - requests.push(url); - const filter = url.searchParams.get('$filter'); - if (filter?.includes('queued')) return Response.json({ value: [active] }); - if (filter?.includes('completed')) { - return Response.json({ value: [completed], nextLink: '/api/FileJob?$after=older-terminal-page' }); - } - return Response.json({ value: [active, completed, older] }); - }); - assert.deepEqual(await data.list('alice'), [active, completed]); - assert.equal(requests.length, 2); - assert.match(requests[0]!.searchParams.get('$filter') ?? '', /queued.*processing/); - assert.match(requests[1]!.searchParams.get('$filter') ?? '', /completed.*failed/); - assert.equal(requests[1]!.searchParams.get('$first'), '100'); -}); - -test('browser job listing keeps the terminal version when a job completes between queries', async () => { - const id = '11111111-1111-4111-8111-111111111111'; - const active = { - id, filename: 'moving.txt', status: 'processing' as const, - sha256: null, byte_count: null, line_count: null, error: null, - }; - const completed = { - ...active, status: 'completed' as const, sha256: 'a'.repeat(64), byte_count: 1, line_count: 1, - }; - let request = 0; - const data = localJobData('http://127.0.0.1:15000', async () => - Response.json({ value: [request++ === 0 ? active : completed] })); - assert.deepEqual(await data.list('alice'), [completed]); -}); - -const deletionOwner = '22222222-2222-4222-8222-222222222222'; -const deletionJob: FileJob = { - id: '11111111-1111-4111-8111-111111111111', filename: 'sample.txt', - status: 'failed', sha256: null, byte_count: null, line_count: null, error: 'worker failed', -}; - -test('failed SQL job deletion preserves the retry snapshot and reports the original error', async () => { - const failure = new Error('SQL unavailable'); - const snapshot = Buffer.from('original bytes'); - let saved: Buffer | undefined = snapshot; - await assert.rejects(deleteLocalJob({ - get: async (oid, id) => { - assert.equal(oid, deletionOwner); assert.equal(id, deletionJob.id); - return deletionJob; - }, - delete: async (oid, id) => { - assert.equal(oid, deletionOwner); assert.equal(id, deletionJob.id); - throw failure; - }, - }, deletionOwner, deletionJob.id, async () => { saved = undefined; }), error => error === failure); - assert.equal(saved, snapshot); -}); - -for (const status of ['completed', 'failed'] as const) { - test(`${status} job deletion removes SQL before its snapshot`, async () => { - const calls: string[] = []; - const removed = await deleteLocalJob({ - get: async () => ({ ...deletionJob, status }), - delete: async () => { calls.push('sql'); }, - }, deletionOwner, deletionJob.id, async () => { calls.push('blob'); }); - assert.equal(removed, true); - assert.deepEqual(calls, ['sql', 'blob']); - }); -} - -test('snapshot deletion failure is reported and a repeat deletion cleans the orphan idempotently', async () => { - let row: FileJob | undefined = deletionJob; - let snapshotExists = true; - let deletes = 0; - let cleanups = 0; - const failure = new Error('Blob unavailable'); - const jobs = { - get: async () => row, - delete: async () => { deletes++; row = undefined; }, - }; - const removeSnapshot = async () => { - cleanups++; - if (cleanups === 1) throw failure; - const removed = snapshotExists; - snapshotExists = false; - return removed; - }; - await assert.rejects(deleteLocalJob(jobs, deletionOwner, deletionJob.id, removeSnapshot), error => error === failure); - assert.equal(row, undefined); - assert.equal(snapshotExists, true); - assert.equal(await deleteLocalJob(jobs, deletionOwner, deletionJob.id, removeSnapshot), false); - assert.equal(snapshotExists, false); - assert.equal(await deleteLocalJob(jobs, deletionOwner, deletionJob.id, removeSnapshot), false); - assert.equal(deletes, 1); -}); - -for (const status of ['queued', 'processing'] as const) { - test(`${status} job deletion rejects before modifying SQL or snapshots`, async () => { - let changed = false; - await assert.rejects(deleteLocalJob({ - get: async () => ({ ...deletionJob, status }), - delete: async () => { changed = true; }, - }, deletionOwner, deletionJob.id, async () => { changed = true; }), { statusCode: 409 }); - assert.equal(changed, false); - }); -} diff --git a/tests/local-tracing.test.ts b/tests/local-tracing.test.ts deleted file mode 100644 index 2364d81..0000000 --- a/tests/local-tracing.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { BlobServiceClient } from '@azure/storage-blob'; -import { localTracing } from '../src/local-tracing.js'; - -test('trace listing downloads records in bounded parallel batches while preserving order', async t => { - let active = 0; - let maximum = 0; - const names = Array.from({ length: 20 }, (_, index) => `trace-${index}`); - const container = { - async *listBlobsFlat() { - for (const name of names) yield { name }; - }, - getBlockBlobClient(name: string) { - const index = Number(name.slice('trace-'.length)); - return { - async downloadToBuffer() { - active++; - maximum = Math.max(maximum, active); - await new Promise(resolve => setTimeout(resolve, 5)); - active--; - return Buffer.from(JSON.stringify({ - traceId: index.toString(16).padStart(32, '0'), - spanId: index.toString(16).padStart(16, '0'), - name, startedAt: '2026-10-11T00:00:00.000Z', - durationMs: 1, status: 'ok', - })); - }, - }; - }, - }; - t.mock.method(BlobServiceClient, 'fromConnectionString', () => ({ - getContainerClient: () => container, - }) as unknown as BlobServiceClient); - const tracing = localTracing('UseDevelopmentStorage=true'); - t.after(() => tracing.shutdown()); - const records = await tracing.list({ oid: 'owner', tenantId: 'tenant', roles: [] }); - assert.deepEqual(records.map(record => record.name), names); - assert.equal(maximum, 8); -}); diff --git a/tests/local.test.ts b/tests/local.test.ts index 09d2328..eda6f10 100644 --- a/tests/local.test.ts +++ b/tests/local.test.ts @@ -28,7 +28,7 @@ function sqlOwnership(runtime: WorkspaceRuntime, args: readonly string[]): strin } test('guided startup reports each existing stage, stops on failure and preserves the error cause', async () => { - const expected = ['start-sql', 'init', 'data', 'services', 'serve']; + const expected = ['start-sql', 'init', 'data', 'serve']; for (let failure = 0; failure <= expected.length; failure++) { const calls: string[] = []; const messages: string[] = []; @@ -40,7 +40,6 @@ test('guided startup reports each existing stage, stops on failure and preserves }; const promise = startLocalApplication('owned-sql', { command: action, - services: container => action('services', container), serve: container => action('serve', container), }, message => messages.push(message)); if (failure === expected.length) { @@ -85,13 +84,13 @@ test('local verification uses master and passes SQL through stdin, not shell int await verifyLocal('sqldbdev', run); }); -test('local DAB uses production entities and RLS with separate claim simulation configuration', async () => { - const local = await localDabConfig() as { runtime: { host: { mode: string; authentication: { provider: string } } }; entities: Record }; +test('local DAB uses production entities with separate claim simulation configuration', async () => { + const local = await localDabConfig({ profile: 'role-based-data', requiredRole: 'AppUser', readinessPath: '/api/AppReady' }) as { runtime: { host: { mode: string; authentication: { provider: string } } }; entities: Record }; const production = JSON.parse(await readFile('dab/dab-config.json', 'utf8')); assert.equal(local.runtime.host.mode, 'development'); assert.equal(local.runtime.host.authentication.provider, 'AppService'); - assert.ok(local.entities.FileJob); - assert.ok(production.entities.FileJob); + assert.deepEqual(Object.keys(local.entities), ['AppReady']); + assert.deepEqual(local.entities, production.entities); assert.equal(production.runtime.host.authentication.provider, 'AzureAD'); assert.equal(production.runtime.host.mode, 'production'); }); diff --git a/tests/maintenance.test.ts b/tests/maintenance.test.ts deleted file mode 100644 index 6069bc4..0000000 --- a/tests/maintenance.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import { deploymentSchema, environmentKey } from '../src/config.js'; -import type { DeploymentState } from '../src/deployment.js'; -import { deployStatic, setSecret, updateImage } from '../src/maintenance.js'; - -const config = deploymentSchema.parse(JSON.parse(await readFile('sql-apps.example.json', 'utf8'))); -const state: DeploymentState = { - environmentKey: environmentKey(config), configHash: 'hash', stage: 'ready', - outputs: { - sqlServer: 'example.database.windows.net', databaseName: 'app', - dabPrincipalId: config.apiClientId, gatewayPrincipalId: config.apiClientId, - gatewayName: 'gateway', gatewayUrl: 'https://gateway.example', functionsName: 'functions', - storageAccount: 'storage', networkId: '/network', vaultName: 'vault', - sqlBackupRetentionDays: 7, sqlBackupRedundancy: 'Geo' as const, sqlAuditWorkspaceName: 'logs', - }, -}; -test('image-only updates use native Azure commands without touching SQL', async () => { - const calls: string[][] = []; - const runner = async (command: string, args: readonly string[]) => { calls.push([command, ...args]); return ''; }; - await updateImage('gateway', config, state, runner); - await updateImage('functions', config, state, runner); - assert.deepEqual(calls.map(call => call.slice(0, 3)), [['az', 'containerapp', 'update'], ['az', 'functionapp', 'config']]); - assert.equal(calls.some(call => call.includes('/Action:Publish')), false); - assert.ok(calls[0]?.includes(config.gatewayImage)); - assert.ok(calls[1]?.includes(config.functionsImage)); -}); -test('secret commands reject invalid inputs and suppress secret output', async () => { - const calls: string[][] = []; - const runner = async (command: string, args: readonly string[]) => { calls.push([command, ...args]); return ''; }; - await assert.rejects(setSecret('../bad', 'value', config, state, runner)); - await assert.rejects(setSecret('valid', '', config, state, runner)); - assert.equal(calls.length, 0); - await setSecret('valid', 'test-secret', config, state, runner); - assert.deepEqual(calls[0]?.slice(-2), ['-o', 'none']); -}); -test('static-only deployment preserves a pinned runtime and changes only public assets', async () => { - const calls: string[][] = []; - const current = `${config.registryServer}/sql-apps@sha256:${'a'.repeat(64)}`; - let built = false; - const runner = async (command: string, args: readonly string[]) => { - calls.push([command, ...args]); - return args.includes('repository') ? `sha256:${'b'.repeat(64)}` : args.includes('show') ? current : ''; - }; - const pinned = await deployStatic(config, state, runner, async () => { built = true; }); - assert.equal(built, true); - assert.ok(calls[1]?.includes(`RUNTIME_IMAGE=${current}`)); - assert.ok(calls[1]?.includes('infra/static.Dockerfile')); - assert.equal(calls.some(call => call.includes('/Action:Publish')), false); - assert.equal(pinned.gatewayImage, `${config.registryServer}/sql-apps@sha256:${'b'.repeat(64)}`); - assert.ok(calls.at(-1)?.includes(pinned.gatewayImage)); - await assert.rejects(deployStatic(config, state, async () => 'image:mutable', async () => {}), /pinned/); -}); diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 797699a..cf43730 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, writeFile, rm, readFile, realpath } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve, join } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -16,7 +16,7 @@ const probe = async (code: string, env: NodeJS.ProcessEnv = {}) => run(process.execPath, ['--input-type=module', '-e', `import { resolveHome, status, main } from ${JSON.stringify(moduleUrl)}; ${code}`], { env }); test('local plugin bundle validates its manifests, matching skill names and isolated sources', async () => { - assert.match(await run(process.execPath, [resolve('scripts', 'check-plugin.mjs')]), /six skills, four design references, MIT license/); + assert.match(await run(process.execPath, [resolve('scripts', 'check-plugin.mjs')]), /six focused skills, MIT license/); assert.equal(await readFile(resolve('plugins', 'sql-apps', 'LICENSE'), 'utf8'), await readFile('LICENSE', 'utf8')); }); @@ -30,7 +30,7 @@ test('SQL Apps identity is consistent across packages, platform manifests and ma assert.equal(manifest.name, 'sql-apps', path); assert.equal(manifest.homepage, repository, path); assert.equal(manifest.repository, repository, path); - assert.equal(manifest.version, '0.1.2', path); + assert.equal(manifest.version, '0.2.0', path); assert.deepEqual(manifest.author, { name: 'Microsoft SQL team (mssql)', url: 'https://github.com/microsoft' }, path); if (manifest.interface) { assert.equal(manifest.interface.displayName, 'SQL Apps'); @@ -56,14 +56,11 @@ test('SQL Apps identity is consistent across packages, platform manifests and ma assert.equal(kimi.displayName, 'SQL Apps'); assert.equal(kimi.source, repository); assert.equal(JSON.parse(await readFile('gemini-extension.json', 'utf8')).name, 'sql-apps'); - for (const [path, name] of [ - ['package.json', 'sql-apps'], ['functions/package.json', 'sql-apps-functions'], - ['functions/local-package.json', 'sql-apps-local-functions'], - ] as const) assert.equal(JSON.parse(await readFile(path, 'utf8')).name, name); + assert.equal(JSON.parse(await readFile('package.json', 'utf8')).name, 'sql-apps'); const lock = JSON.parse(await readFile('package-lock.json', 'utf8')); assert.equal(lock.name, 'sql-apps'); assert.equal(lock.packages[''].name, 'sql-apps'); - assert.deepEqual(JSON.parse(await readFile('application.json', 'utf8')), { name: 'sql-apps-foundation', selectedExamples: [] }); + assert.deepEqual(JSON.parse(await readFile('application.json', 'utf8')), { name: 'sql-apps', selectedExamples: [] }); assert.match(await readFile('skills/sql-apps-getting-started/SKILL.md', 'utf8'), /^---\r?\nname: sql-apps-getting-started\r?\n/); }); @@ -76,7 +73,6 @@ test('SQL Apps UI, wordmark and private-state exclusions use the new brand', asy const lines = (await readFile(path, 'utf8')).split(/\r?\n/); assert.ok(lines.includes(path === '.gitignore' ? '.sql-apps/' : '.sql-apps')); assert.ok(lines.includes('sql-apps.json')); - assert.ok(lines.includes('functions/local.settings.json')); } }); @@ -97,9 +93,9 @@ test('product guidance documents authentication boundaries and hosting accuratel ]) assert.match(authentication, requirement); }); -test('completed job output wraps long hashes in the shared browser UI', async () => { +test('readiness output wraps long values in the shared browser UI', async () => { const css = await readFile(resolve('public', 'style.css'), 'utf8'); - assert.match(css, /#jobs\s+li\s*\{[^}]*overflow-wrap:\s*anywhere\s*;?[^}]*\}/); + assert.match(css, /pre\s*\{[^}]*overflow-wrap:\s*anywhere\s*;?[^}]*\}/); }); test('setup safety is enforced by executable coordinator paths', async () => { @@ -122,17 +118,11 @@ test('application boundary retains the proven SQL storage and mutation rules', a ]) assert.match(boundary, requirement); }); -test('SQL Apps design guidance progressively discloses packaged references', async () => { - const craftFloor = await readFile(resolve('plugins', 'sql-apps', 'skills', 'sql-apps-design', 'references', 'craft-floor.md'), 'utf8'); - const uiRecipes = await readFile(resolve('plugins', 'sql-apps', 'skills', 'sql-apps-design', 'references', 'ui-style-recipes.md'), 'utf8'); - const visualRecipes = await readFile(resolve('plugins', 'sql-apps', 'skills', 'sql-apps-design', 'references', 'visual-style-recipes.md'), 'utf8'); - const formatting = await readFile(resolve('plugins', 'sql-apps', 'skills', 'sql-apps-design', 'references', 'formatting.md'), 'utf8'); - assert.ok(craftFloor.length > 1000); - assert.ok(uiRecipes.length > 1000); - assert.ok(visualRecipes.length > 1000); - assert.ok(formatting.length > 1000); +test('SQL Apps delegates general visual design and retains app-specific state requirements', async () => { const design = await readFile(resolve('plugins', 'sql-apps', 'skills', 'sql-apps-design', 'SKILL.md'), 'utf8'); - assert.match(design, /references\/craft-floor\.md/); + assert.match(design, /maintained frontend or design skill/); + assert.match(design, /loading, empty, error, disabled, save, and reload states/i); + assert.doesNotMatch(design, /references\/|craft floor|style recipes/i); const guide = await readFile(resolve('docs', 'guides', 'build-your-app.md'), 'utf8'); assert.match(guide, /`sql-apps-design` skill/); }); @@ -145,10 +135,6 @@ test('agent guidance stays concise, stack-neutral and script-led', async () => { const cloud = await read('plugins/sql-apps/skills/sql-apps-cloud-preview/SKILL.md'); const validation = await read('plugins/sql-apps/skills/sql-apps-validation/SKILL.md'); const design = await read('plugins/sql-apps/skills/sql-apps-design/SKILL.md'); - const craftFloor = await read('plugins/sql-apps/skills/sql-apps-design/references/craft-floor.md'); - const uiRecipes = await read('plugins/sql-apps/skills/sql-apps-design/references/ui-style-recipes.md'); - const visualRecipes = await read('plugins/sql-apps/skills/sql-apps-design/references/visual-style-recipes.md'); - const formatting = await read('plugins/sql-apps/skills/sql-apps-design/references/formatting.md'); const buildGuide = await read('docs/guides/build-your-app.md'); const runGuide = await read('docs/guides/run-locally.md'); const guideReference = await read('docs/reference/guide.md'); @@ -163,26 +149,18 @@ test('agent guidance stays concise, stack-neutral and script-led', async () => { assert.doesNotMatch(application, /Then implement the first useful change/); assert.doesNotMatch(application, /npm run build[\s\S]*npm test/, 'the application skill must delegate command selection'); assert.doesNotMatch(local, /setup-check/, 'setup plan is the normal prerequisite probe'); - assert.match(cloud, /For a cost review[\s\S]*docs\/guides\/costs\.md/); - assert.match(cloud, /For deployment[\s\S]*docs\/reference\/deployment\.md/); + assert.match(cloud, /azure-prepare[\s\S]*azure-validate[\s\S]*azure-deploy/); + assert.match(cloud, /azure-cost/); + assert.match(cloud, /compatibility path for existing projects/); assert.doesNotMatch(readme, /Static Web Apps candidate/); assert.doesNotMatch(readme, /uses TypeScript, Azure SQL Database, Data API builder, Blob Storage and Azure Functions/); await assert.rejects(read('docs/superpowers/plans/2026-10-10-bakery-ordering.md')); await assert.rejects(read('docs/superpowers/specs/2026-10-10-bakery-ordering-design.md')); assert.match(await read('docs/README.md'), /Temporary implementation plans.*not product guidance/is); - for (const [path, content] of [ - ['ui-style-recipes.md', uiRecipes], - ['visual-style-recipes.md', visualRecipes], - ['formatting.md', formatting], - ] as const) { - assert.doesNotMatch(content, /src\/global\.css|@\/lib\/utils|Radix|Tailwind|VegaVisual|DataGrid|fabric-visuals/i, path); - } - assert.doesNotMatch(craftFloor, /always wrong/); - assert.doesNotMatch(formatting, /must be strictly followed/i); - assert.ok(uiRecipes.split(/\s+/).length < 700, 'UI guidance should fit its context budget'); - assert.ok(visualRecipes.split(/\s+/).length < 600, 'visual guidance should fit its context budget'); - assert.ok(formatting.split(/\s+/).length < 500, 'formatting guidance should fit its context budget'); + assert.match(design, /maintained frontend or design skill/); + assert.match(design, /Product screens show real account and access states/); + assert.doesNotMatch(design, /references\/|craft floor|style recipes/i); const statusPhrase = /implemented.*built.*running.*workflow[- ]verified/gi; const skillMentions = [gettingStarted, application, local, cloud, validation, design] @@ -242,7 +220,7 @@ test('plugin status is bounded, read-only and distinguishes healthy, HTTP failur }); test('plugin dispatch uses checkout cwd, passes literal arguments and preserves CLI failure', async t => { - const home = await mkdtemp(join(tmpdir(), 'sql apps dispatch-')); + const home = await realpath(await mkdtemp(join(tmpdir(), 'sql apps dispatch-'))); t.after(() => rm(home, { recursive: true, force: true })); for (const folder of ['src', 'sql', 'plugins/sql-apps', 'dist/src', 'scripts']) await mkdir(join(home, folder), { recursive: true }); await writeFile(join(home, 'package.json'), JSON.stringify({ name: 'sql-apps', scripts: { local: 'node dist/src/local-cli.js' } })); @@ -260,18 +238,19 @@ test('plugin dispatch uses checkout cwd, passes literal arguments and preserves const output = JSON.parse(await run(process.execPath, [launcher, 'verify', 'custom-sql'], { env: { SQL_APPS_HOME: home } })); assert.equal(output.cwd, home); assert.deepEqual(output.args, ['verify', 'custom-sql']); - for (const command of ['role-based-app', 'role-based-serve']) { + // Dispatch checks below only read the fixture, so independent launches run concurrently. + await Promise.all(['role-based-app', 'role-based-serve'].map(async command => { const selected = JSON.parse(await run(process.execPath, [launcher, command, 'custom-sql'], { env: { SQL_APPS_HOME: home } })); assert.equal(selected.cwd, home); assert.deepEqual(selected.args, [command, 'custom-sql']); - } + })); const artifact = join(home, '.sql-apps', 'selected artifact; literal'); - for (const command of ['selected-app', 'selected-serve', 'selected-test']) { + await Promise.all(['selected-app', 'selected-serve', 'selected-test'].map(async command => { const selected = JSON.parse(await run(process.execPath, [launcher, command, artifact], { env: { SQL_APPS_HOME: home } })); assert.equal(selected.cwd, home); assert.deepEqual(selected.args, [command, artifact]); await assert.rejects(run(process.execPath, [launcher, command, 'relative-artifact'], { env: { SQL_APPS_HOME: home } }), /absolute artifact/); - } + })); const stopped = JSON.parse(await run(process.execPath, [launcher, 'selected-stop', 'inventory'], { env: { SQL_APPS_HOME: home } })); assert.deepEqual(stopped.args, ['selected-stop', 'inventory']); const configuredStatus = JSON.parse(await probe(` @@ -312,22 +291,24 @@ test('plugin dispatch uses checkout cwd, passes literal arguments and preserves assert.deepEqual(beforeBuild.args, ['--json']); await writeFile(join(home, 'scripts/local-consent.mjs'), 'console.log(JSON.stringify({cwd:process.cwd(),args:process.argv.slice(2)}));'); - for (const [command, args] of [ - ['consent-plan', ['role-based-app', 'custom-sql']], - ['consent-status', ['build']], - ['consent-grant', [briefPath]], - ['consent-grant-once', [briefPath]], - ['consent-terms', [briefPath]], - ['consent-revoke', ['terms']], - ['consent-run', ['build']], - ] as const) { - const consent = JSON.parse(await run(process.execPath, [launcher, command, ...args], { env: { SQL_APPS_HOME: home } })); - assert.equal(consent.cwd, home); - assert.deepEqual(consent.args, [command.replace('consent-', ''), ...args]); - } - await assert.rejects(run(process.execPath, [launcher, 'consent-grant', 'relative.json'], { env: { SQL_APPS_HOME: home } }), /absolute/); - await assert.rejects(run(process.execPath, [launcher, 'consent-plan'], { env: { SQL_APPS_HOME: home } }), /operation/); - await assert.rejects(run(process.execPath, [launcher, 'setup-check', 'fail-sql'], { env: { SQL_APPS_HOME: home } }), /failed \(7\)/); + await Promise.all([ + ...([ + ['consent-plan', ['role-based-app', 'custom-sql']], + ['consent-status', ['build']], + ['consent-grant', [briefPath]], + ['consent-grant-once', [briefPath]], + ['consent-terms', [briefPath]], + ['consent-revoke', ['terms']], + ['consent-run', ['build']], + ] as const).map(async ([command, args]) => { + const consent = JSON.parse(await run(process.execPath, [launcher, command, ...args], { env: { SQL_APPS_HOME: home } })); + assert.equal(consent.cwd, home); + assert.deepEqual(consent.args, [command.replace('consent-', ''), ...args]); + }), + assert.rejects(run(process.execPath, [launcher, 'consent-grant', 'relative.json'], { env: { SQL_APPS_HOME: home } }), /absolute/), + assert.rejects(run(process.execPath, [launcher, 'consent-plan'], { env: { SQL_APPS_HOME: home } }), /operation/), + assert.rejects(run(process.execPath, [launcher, 'setup-check', 'fail-sql'], { env: { SQL_APPS_HOME: home } }), /failed \(7\)/), + ]); await mkdir(join(home, 'dist/src'), { recursive: true }); await writeFile(join(home, 'dist/src/local-cli.js'), 'process.exitCode=7;'); await recordFixture(home); diff --git a/tests/process.test.ts b/tests/process.test.ts index 80cc236..c94c660 100644 --- a/tests/process.test.ts +++ b/tests/process.test.ts @@ -15,16 +15,14 @@ test('command runner passes arguments literally without shell interpolation', as assert.equal(output, argument); }); -test('command runner redacts SQL access tokens and secret values from errors', async () => { - const sqlToken = 'test-only-database-access-token'; - const secret = 'test-only-vault-value'; +test('command runner redacts declared secret values from errors', async () => { + const secret = 'test-only-sql-password'; await assert.rejects(run(process.execPath, [ '-e', 'process.stderr.write(process.argv.slice(1).join(" "));process.exitCode=7', - `/AccessToken:${sqlToken}`, '--value', secret, - ]), (error: unknown) => { + 'password', secret, + ], { redact: [secret] }), (error: unknown) => { assert.ok(error instanceof Error); assert.match(error.message, /failed \(7\)/); - assert.equal(error.message.includes(sqlToken), false); assert.equal(error.message.includes(secret), false); assert.match(error.message, /\[REDACTED\]/); return true; diff --git a/tests/projects.test.mjs b/tests/projects.test.mjs index 3c27626..7b27d67 100644 --- a/tests/projects.test.mjs +++ b/tests/projects.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { access, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { access, mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -9,7 +9,7 @@ const plugin = await import('../plugins/sql-apps/scripts/sql-apps.mjs'); const workspace = await import('../scripts/workspace-check.mjs'); async function sourceFixture(t) { - const source = await mkdtemp(join(tmpdir(), 'sql-apps-project-source-')); + const source = await realpath(await mkdtemp(join(tmpdir(), 'sql-apps-project-source-'))); t.after(() => rm(source, { recursive: true, force: true })); for (const directory of ['.config', 'dab', 'docs/guides', 'functions/src', 'infra', 'public', 'scripts', 'sql', 'src']) { await mkdir(join(source, directory), { recursive: true }); @@ -139,7 +139,7 @@ test('concurrent project creation preserves every registry entry', async t => { test('open registers an existing SQL Apps project and list reports all projects', async t => { const source = await sourceFixture(t); - const userHome = await mkdtemp(join(tmpdir(), 'sql-apps-project-open-')); + const userHome = await realpath(await mkdtemp(join(tmpdir(), 'sql-apps-project-open-'))); t.after(() => rm(userHome, { recursive: true, force: true })); const first = await projects.createProject(source, 'First App', { userHome, initializeGit: false }); const external = join(userHome, 'elsewhere', 'second-app'); @@ -160,7 +160,7 @@ test('open registers an existing SQL Apps project and list reports all projects' test('plugin accepts generated projects and resolves the current or active project before its foundation binding', async t => { const source = await sourceFixture(t); - const userHome = await mkdtemp(join(tmpdir(), 'sql-apps-project-routing-')); + const userHome = await realpath(await mkdtemp(join(tmpdir(), 'sql-apps-project-routing-'))); t.after(() => rm(userHome, { recursive: true, force: true })); const created = await projects.createProject(source, 'Current App', { userHome, initializeGit: false }); assert.equal(await plugin.validateHome(created.path), created.path); diff --git a/tests/role-based-data.test.ts b/tests/role-based-data.test.ts index 8b581fd..e44d292 100644 --- a/tests/role-based-data.test.ts +++ b/tests/role-based-data.test.ts @@ -35,8 +35,6 @@ test('cloud runtime rejects insecure or credential-bearing upstream URLs', () => for (const changed of [ { DAB_URL: 'http://data.internal' }, { DAB_URL: 'https://user:password@data.internal' }, - { FUNCTIONS_URL: 'http://functions.internal' }, - { FUNCTIONS_URL: 'https://user:password@functions.internal' }, ]) assert.throws(() => loadRuntimeConfig({ ...environment, ...changed }), /HTTPS|credentials/); }); test('role-based local commands require their profile before starting any services', async t => { @@ -80,13 +78,12 @@ test('authorized claims are retained only from validated delegated tokens', () = assert.deepEqual(userFromClaims({ oid, tid, scp: 'access_as_user', roles: ['AppUser'] }, tid).roles, ['AppUser']); assert.throws(() => userFromClaims({ oid, tid, scp: 'access_as_user', roles: 'AppUser' }, tid)); }); -test('authorized startup omits storage and Functions stages', async () => { +test('authorized startup runs only SQL, data and gateway stages', async () => { const steps: string[] = []; await startLocalApplication('owned-sql', { command: async step => { steps.push(step); }, - services: async () => { throw new Error('Excluded service started'); }, serve: async () => { steps.push('serve'); }, - }, () => {}, true); + }, () => {}); assert.deepEqual(steps, ['start-sql', 'init', 'data', 'serve']); }); test('local authorized profile denies users without the required role and forwards only trusted authorized role', async t => { @@ -94,7 +91,7 @@ test('local authorized profile denies users without the required role and forwar const app = await createLocalApp(async (_url, options) => { forwarded = new Headers(options?.headers); return Response.json({ value: [] }); - }, undefined, profile); + }, profile); t.after(() => app.close()); const headers = { host: new URL(localAppOrigin).host, origin: localAppOrigin }; const session = async (user: string) => (await app.inject({ @@ -112,12 +109,14 @@ test('local authorized profile denies users without the required role and forwar assert.equal((await app.inject({ url: '/storage', headers: { ...headers, authorization: `Bearer ${authorized}` } })).statusCode, 404); assert.equal((await app.inject({ url: '/health/ready', headers })).statusCode, 200); }); -test('authorized deployment needs no Functions image but requires authorized role and readiness', () => { +test('authorized deployment requires the authorized role and readiness', () => { const base = { subscriptionId: developmentUsers[0]!.id, tenantId: developmentUsers[0]!.id, apiClientId: developmentUsers[0]!.id, sqlAdminObjectId: developmentUsers[0]!.id, sqlAdminName: 'operator', resourceGroup: 'data-app', location: 'eastus', environment: 'dev', name: 'data-app', gatewayImage: 'app.azurecr.io/gateway:1', dabImage: 'app.azurecr.io/data:1', registryServer: 'app.azurecr.io' }; - assert.equal(deploymentSchema.parse({ ...base, ...profile }).functionsImage, ''); + assert.equal('functionsImage' in deploymentSchema.parse({ ...base, ...profile }), false); + assert.equal(deploymentSchema.parse({ ...base, ...profile, functionsImage: '' }).functionsImage, ''); + assert.throws(() => deploymentSchema.parse({ ...base, ...profile, functionsImage: 'app.azurecr.io/functions:1' })); assert.throws(() => deploymentSchema.parse({ ...base, profile: 'role-based-data' })); assert.throws(() => deploymentSchema.parse(base)); }); @@ -130,11 +129,9 @@ test('cloud authorized gateway derives the DAB role from verified claims and has }); let forwarded = new Headers(); let calls = 0; - const unavailable = async (): Promise => { throw new Error('Excluded adapter used'); }; const app = await createGateway(config, { verifyUser: async token => ({ oid: developmentUsers[0]!.id, tenantId: tid, roles: token === 'authorized' ? ['AppUser'] : [] }), - files: { put: unavailable, get: unavailable, delete: unavailable }, functionToken: unavailable, fetch: async (_url, options) => { calls++; forwarded = new Headers(options?.headers); return Response.json({ value: [] }); }, }); t.after(() => app.close()); @@ -151,11 +148,12 @@ test('cloud authorized gateway derives the DAB role from verified claims and has } const browser = (await app.inject('/auth/config')).json(); assert.equal(browser.mode, 'entra'); - assert.deepEqual(browser.capabilities, { files: false, functions: false }); + assert.equal(browser.readinessPath, profile.readinessPath); + assert.equal('capabilities' in browser, false); }); test('authorized local readiness exposes actual procedure failures', async t => { - const app = await createLocalApp(async () => new Response('{}', { status: 403 }), undefined, profile); + const app = await createLocalApp(async () => new Response('{}', { status: 403 }), profile); t.after(() => app.close()); assert.equal((await app.inject({ url: '/health/ready', headers: { host: new URL(localAppOrigin).host } })).statusCode, 503); }); @@ -168,7 +166,7 @@ test('role-based initial readiness uses only the configured authorized procedure assert.equal(new URL(String(url)).pathname, profile.readinessPath); assert.equal(new Headers(options?.headers).get('x-ms-api-role'), profile.requiredRole); return Response.json({ value: [] }); - }, undefined, profile); + }, profile); t.after(() => app.close()); await waitForLocalReadiness(app, async () => { assert.fail('ready procedure must not retry'); }); assert.equal(queries, 1); @@ -177,7 +175,6 @@ test('role-based initial readiness uses only the configured authorized procedure test('role-based current-user access does not authorize denied users for domain routes', async t => { const tid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; let upstream = 0; - const unavailable = async (): Promise => { throw new Error('Excluded adapter used'); }; const app = await createGateway(loadRuntimeConfig({ SQL_APPS_PROFILE: profile.profile, SQL_APPS_REQUIRED_ROLE: profile.requiredRole, SQL_APPS_READINESS_PATH: profile.readinessPath, AZURE_TENANT_ID: tid, API_CLIENT_ID: tid, @@ -188,7 +185,6 @@ test('role-based current-user access does not authorize denied users for domain return { oid: developmentUsers[0]!.id, tenantId: tid, roles: token === 'allowed' ? [profile.requiredRole] : [] }; }, - files: { put: unavailable, get: unavailable, delete: unavailable }, functionToken: unavailable, fetch: async () => { upstream++; return Response.json({ value: [] }); }, }); t.after(() => app.close()); diff --git a/tests/role-based-deployment.test.ts b/tests/role-based-deployment.test.ts index d87fc0e..4adb18a 100644 --- a/tests/role-based-deployment.test.ts +++ b/tests/role-based-deployment.test.ts @@ -1,20 +1,23 @@ import { test, after } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, writeFile, rm, unlink, readFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, writeFile, rm, unlink, readFile, realpath } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { deploymentSchema, environmentKey } from '../src/config.js'; -import { deploy, preflight, provision, saveState, statePath, armParameters, deploymentConfigHash, type DeploymentState } from '../src/deployment.js'; +import { + deploy, preflight, provision, saveState, readState, statePath, armParameters, deploymentConfigHash, + expectedRbacAssignments, type DeploymentState, +} from '../src/deployment.js'; import { buildArtifacts } from '../src/artifacts.js'; -import { reviewRoleBasedCost } from '../src/role-based-cost.js'; import { assignApplicationRole } from '../src/role-based-identity.js'; import { roleBasedSmoke } from '../src/role-based-smoke.js'; +import { roleBasedSchemaFingerprint } from '../src/role-based-profile.js'; import type { Run } from '../src/process.js'; const toolingRoot = resolve('.'); const original = process.cwd(); -const config = deploymentSchema.parse(JSON.parse(await readFile('azure-role-based.example.json', 'utf8'))); -const directory = await mkdtemp(join(tmpdir(), 'sql-apps-role-based-deploy-')); +const example = JSON.parse(await readFile('azure-role-based.example.json', 'utf8')); +const directory = await realpath(await mkdtemp(join(tmpdir(), 'sql-apps-role-based-deploy-'))); process.chdir(directory); after(async () => { process.chdir(original); await rm(directory, { recursive: true, force: true }); }); await mkdir('dab'); @@ -29,12 +32,12 @@ await writeFile(join('dab', 'dab-config.json'), JSON.stringify({ rest: { enabled: true, methods: ['get'] }, permissions: [{ role: 'AppUser', actions: ['execute'] }], } }, })); +const config = deploymentSchema.parse({ ...example, imageSource: await roleBasedSchemaFingerprint(directory) }); const outputs = { sqlServer: 'data-app.database.windows.net', databaseName: 'app', dabPrincipalId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', gatewayPrincipalId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', - gatewayName: 'app-gateway', gatewayUrl: 'https://data-app.azurecontainerapps.io', - functionsName: '', networkId: '/network', storageAccount: '', vaultName: '', + gatewayName: 'app-gateway', gatewayUrl: 'https://data-app.azurecontainerapps.io', networkId: '/network', sqlBackupRetentionDays: 7, sqlBackupRedundancy: 'Geo' as const, sqlAuditWorkspaceName: 'logs', } as const; const digest = `sha256:${'a'.repeat(64)}`; @@ -52,6 +55,12 @@ function runner(failRuntime = false) { }); if (args[0] === 'provider') return 'Registered'; if (args[0] === 'acr' && args[1] === 'repository') return digest; + if (args[0] === 'role' && args[1] === 'assignment' && args[2] === 'list') { + const scope = args[args.indexOf('--scope') + 1]!; + return JSON.stringify(expectedRbacAssignments(config, outputs) + .filter(item => item.scope === scope) + .map(item => ({ principalId: item.principalId, roleDefinitionId: item.roleDefinitionId, scope }))); + } if (args[0] === 'deployment') { if (failRuntime && args.includes(`${config.name}-${config.environment}-runtime`)) throw new Error('Runtime failed'); return JSON.stringify(Object.fromEntries(Object.entries(outputs).map(([key, value]) => [key, { value }]))); @@ -60,21 +69,23 @@ function runner(failRuntime = false) { }; return { run, calls }; } -test('authorized cost review matches paid template and never infers spending approval', () => { - const report = reviewRoleBasedCost({ version: 1, profile: 'role-based-data', intent: 'zero-azure-spend', acknowledgeFixedCharges: false }); - assert.equal(report.review.canProceedToWhatIf, false); - assert.equal(report.estimatedMonthlyCost, null); - assert.equal(report.deployment.authorized, false); - assert.equal(report.resources[0]!.sku, 'S0'); - assert.throws(() => reviewRoleBasedCost({ version: 1, profile: 'public-demo', intent: 'review-paid-costs', acknowledgeFixedCharges: true })); -}); test('authorized artifacts publish only gateway and DAB images', async () => { const { run, calls } = runner(); const pinned = await buildArtifacts(config, run); - assert.equal(pinned.functionsImage, ''); + assert.equal(pinned.imageSource, config.imageSource); + assert.equal(pinned.gatewayImage, `yourregistry.azurecr.io/app-gateway@${digest}`); assert.deepEqual(calls.filter(call => call.includes('build')).map(call => call[call.indexOf('--file') + 1]), [join(toolingRoot, 'Dockerfile'), join(toolingRoot, 'dab', 'Dockerfile')]); }); +test('rebuilding pinned images publishes fresh timestamp tags instead of overwriting the pinned tag', async () => { + const { run, calls } = runner(); + const previous = { ...config, gatewayImage: `yourregistry.azurecr.io/app-gateway:0.2.0@${digest}`, + dabImage: `yourregistry.azurecr.io/app-data@${digest}`, imageSource: 'f'.repeat(64) }; + const pinned = await buildArtifacts(previous, run, new Date('2025-03-04T05:06:07.890Z')); + assert.deepEqual(calls.filter(call => call.includes('build')).map(call => call[call.indexOf('--image') + 1]), + ['app-gateway:20250304-050607', 'app-data:20250304-050607']); + assert.equal(pinned.imageSource, config.imageSource); +}); test('authorized preflight and deployment omit excluded providers and function-role grants', async t => { const { run, calls } = runner(); const stages: string[] = []; @@ -88,7 +99,10 @@ test('authorized preflight and deployment omit excluded providers and function-r assert.equal(calls.some(call => call.includes('POST') && call.includes('rest')), false); const publish = calls.find(call => call.includes('/Action:Publish'))!; assert.ok(publish.some(value => value.startsWith('/v:RoleBasedProcedureGrants=GRANT EXECUTE ON OBJECT::[dbo].[AppReady]'))); - assert.deepEqual(armParameters(config, true).parameters.profile, { value: 'role-based-data' }); + const parameters = armParameters(config, true).parameters; + for (const local of ['profile', 'imageSource', 'functionsImage', 'subscriptionId', 'resourceGroup']) { + assert.equal(local in parameters, false, `${local} is not a Bicep parameter`); + } }); test('authorized deploy resumes retained schema stage without provisioning or publishing again', async t => { const prior: DeploymentState = { @@ -102,6 +116,16 @@ test('authorized deploy resumes retained schema stage without provisioning or pu await deploy(config, run, async () => new Response('{}'), async () => {}); assert.equal(calls.some(call => call.includes('/Action:Publish') || call.includes(`${config.name}-${config.environment}-infrastructure`)), false); }); +test('0.1.x deployment state with foundation outputs still parses', async t => { + const prior = { + environmentKey: environmentKey(config), configHash: await deploymentConfigHash(config), stage: 'schema', + outputs: { ...outputs, functionPrincipalId: '', functionsName: '', storageAccount: '', vaultName: '' }, applicationHome: directory, + }; + await saveState(statePath(config), prior as DeploymentState); + t.after(() => unlink(statePath(config))); + assert.equal((await readState(config)).outputs.gatewayUrl, outputs.gatewayUrl); +}); + test('failed authorized runtime retains schema stage and never claims readiness', async t => { const { run } = runner(true); const stages: string[] = []; @@ -122,9 +146,11 @@ test('authorized deployment refuses copied state and changed SQL source before r await saveState(statePath(config), { ...prior, applicationHome: directory }); assert.equal((await provision(config, run)).stage, 'schema'); await writeFile(join('sql', 'AppReady.sql'), 'CREATE PROCEDURE dbo.AppReady AS SELECT 2 AS ready;'); - await assert.rejects(deploy(config, run, async () => new Response('{}'), async () => {}), /SQL\/DAB source changed/); - await assert.rejects(provision(config, run), /SQL\/DAB source changed/); + await assert.rejects(deploy(config, run, async () => new Response('{}'), async () => {}), /run artifacts, then deploy/); assert.equal(calls.some(call => call.includes('deployment') || call.includes('/Action:Publish')), false); + // Changed source re-applies infrastructure instead of trapping the environment on its first version. + assert.equal((await provision(config, run)).stage, 'infrastructure'); + assert.ok(calls.some(call => call.includes(`${config.name}-${config.environment}-infrastructure`))); } finally { await writeFile(join('sql', 'AppReady.sql'), 'CREATE PROCEDURE dbo.AppReady AS SELECT 1 AS ready;'); await unlink(statePath(config)); diff --git a/tests/role-based-infra.test.mjs b/tests/role-based-infra.test.mjs index f7ff6ff..1b0361d 100644 --- a/tests/role-based-infra.test.mjs +++ b/tests/role-based-infra.test.mjs @@ -4,68 +4,68 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve, join } from 'node:path'; import { run } from '../dist/src/process.js'; -import { reviewRoleBasedCost } from '../dist/src/role-based-cost.js'; -test('compiled authorized profile gates excluded resources and retains private SQL and trusted authorized settings', async t => { +test('compiled templates deploy only private SQL, DAB and the gateway with trusted authorized settings', async t => { const directory = await mkdtemp(join(tmpdir(), 'sql-apps-role-based-infra-')); t.after(() => rm(directory, { recursive: true, force: true })); const output = join(directory, 'main.json'); + const rbacOutput = join(directory, 'rbac.json'); await run('az', ['bicep', 'build', '--file', resolve('infra', 'main.bicep'), '--outfile', output]); + await run('az', ['bicep', 'build', '--file', resolve('infra', 'rbac.bicep'), '--outfile', rbacOutput]); const template = JSON.parse(await readFile(output, 'utf8')); - assert.equal(template.parameters.profile.defaultValue, 'foundation'); - assert.deepEqual(template.parameters.profile.allowedValues, ['foundation', 'role-based-data']); - assert.equal(template.variables.fullFoundation, "[equals(parameters('profile'), 'foundation')]"); - for (const resource of template.resources.filter(resource => - /Microsoft\.(Storage|Web|KeyVault)\/|Microsoft\.Insights\/components/.test(resource.type))) { - assert.equal(resource.condition, "[variables('fullFoundation')]", `Excluded resource ${resource.type} must be gated`); - } + const rbac = JSON.parse(await readFile(rbacOutput, 'utf8')); + assert.equal(template.parameters.profile, undefined, 'role-based-data is the only profile; nothing to select'); + assert.equal(template.variables?.fullFoundation, undefined); + assert.equal(template.resources.some(resource => + /Microsoft\.(Storage|Web|KeyVault)\/|Microsoft\.Insights\/components/.test(resource.type)), false, + 'Functions, Storage, Key Vault and Application Insights belong to the maintained Azure skills, not SQL Apps'); const audit = template.resources.find(resource => resource.type === 'Microsoft.Sql/servers/auditingSettings'); - assert.equal(audit.condition, undefined, 'SQL auditing must apply to both profiles'); + assert.equal(audit.condition, undefined); assert.equal(audit.properties.state, 'Enabled'); assert.equal(audit.properties.isAzureMonitorTargetEnabled, true); const auditLogs = template.resources.find(resource => resource.type === 'Microsoft.Insights/diagnosticSettings'); - assert.equal(auditLogs.condition, undefined, 'SQL audit delivery must apply to both profiles'); + assert.equal(auditLogs.condition, undefined); assert.ok(auditLogs.properties.logs.some(entry => entry.category === 'SQLSecurityAuditEvents' && entry.enabled)); assert.match(JSON.stringify(auditLogs.properties.workspaceId), /workspaces/); const retention = template.resources.find(resource => resource.type === 'Microsoft.Sql/servers/databases/backupShortTermRetentionPolicies'); assert.equal(retention.properties.retentionDays, "[parameters('sqlBackupRetentionDays')]"); assert.equal(template.parameters.sqlBackupRetentionDays.defaultValue, 7); assert.equal(template.parameters.sqlBackupRetentionDays.maxValue, 35); - assert.equal(template.parameters.sqlBackupRedundancy.defaultValue, 'Geo'); + assert.equal(template.parameters.sqlBackupRedundancy.defaultValue, 'Local', 'matches what 0.1.x role-based deployments already used'); for (const key of ['sqlBackupRetentionDays', 'sqlBackupRedundancy', 'sqlAuditWorkspaceName']) { assert.ok(template.outputs[key], `status must be able to report ${key}`); } - for (const name of ['vault-endpoint', 'function-endpoint']) { - assert.equal(template.resources.find(resource => resource.name === name).condition, "[variables('fullFoundation')]"); - } const sql = template.resources.find(resource => resource.type === 'Microsoft.Sql/servers'); assert.equal(sql.properties.publicNetworkAccess, 'Disabled'); assert.equal(sql.properties.administrators.azureADOnlyAuthentication, true); const sqlEndpoint = template.resources.find(resource => resource.name === 'sql-endpoint'); assert.equal(sqlEndpoint.condition, undefined); - const cost = reviewRoleBasedCost(JSON.parse(await readFile('azure-role-based-cost.example.json', 'utf8'))); const database = template.resources.find(resource => resource.type === 'Microsoft.Sql/servers/databases'); - assert.equal(database.sku.name, cost.resources[0].sku); - for (const resource of template.resources.filter(resource => resource.type === 'Microsoft.App/containerApps')) { - assert.equal(resource.properties.template.scale.minReplicas, cost.resources[2].minReplicas); - assert.equal(resource.properties.template.scale.maxReplicas, cost.resources[2].maxReplicas); + assert.deepEqual(database.sku, { name: 'GP_S_Gen5_2', tier: 'GeneralPurpose', family: 'Gen5', capacity: 2 }); + assert.equal(database.properties.useFreeLimit, true); + assert.equal(database.properties.freeLimitExhaustionBehavior, 'AutoPause'); + assert.equal(database.properties.autoPauseDelay, 60); + const apps = template.resources.filter(resource => resource.type === 'Microsoft.App/containerApps'); + assert.equal(apps.length, 2); + for (const resource of apps) { + assert.equal(resource.properties.template.scale.minReplicas, 0); + assert.equal(resource.properties.template.scale.maxReplicas, 2); assert.equal(resource.properties.template.containers[0].resources.cpu, "[json('0.5')]"); assert.equal(resource.properties.template.containers[0].resources.memory, '1Gi'); assert.equal(resource.properties.configuration.ingress.allowInsecure, false); } - const data = template.resources.find(resource => resource.type === 'Microsoft.App/containerApps' && - resource.properties.template.containers[0].name === 'data'); + const data = apps.find(resource => resource.properties.template.containers[0].name === 'data'); assert.equal(data.properties.configuration.ingress.external, false); - const gateway = template.resources.find(resource => resource.type === 'Microsoft.App/containerApps' && - resource.properties.template.containers[0].name === 'gateway'); + const gateway = apps.find(resource => resource.properties.template.containers[0].name === 'gateway'); assert.equal(gateway.properties.configuration.ingress.external, true); - const env = gateway.properties.template.containers[0].env; - assert.match(env, /if\(variables\('fullFoundation'\)/); - assert.match(env, /SQL_APPS_PROFILE.*SQL_APPS_REQUIRED_ROLE.*SQL_APPS_READINESS_PATH/); - const pulls = template.resources.find(resource => resource.copy?.name === 'pullRoles'); - assert.match(pulls.copy.count, /if\(variables\('fullFoundation'\), 3, 2\)/); - const storageRoles = template.resources.find(resource => resource.copy?.name === 'hostStorageRoles'); - assert.match(storageRoles.copy.count, /if\(variables\('fullFoundation'\).*createArray\(\)/); - const storageEndpoints = template.resources.find(resource => resource.copy?.name === 'storageEndpoints'); - assert.match(storageEndpoints.copy.count, /if\(variables\('fullFoundation'\).*createArray\(\)/); + const env = JSON.stringify(gateway.properties.template.containers[0].env); + assert.match(env, /SQL_APPS_REQUIRED_ROLE.*SQL_APPS_READINESS_PATH/); + assert.equal(template.resources.some(resource => resource.type === 'Microsoft.Authorization/roleAssignments'), false, + 'ordinary infrastructure deployment must not require roleAssignments/write'); + const pulls = rbac.resources.find(resource => resource.copy?.name === 'pullRoles'); + assert.equal(pulls.copy.count, "[length(createArray(parameters('gatewayPrincipalId'), parameters('dabPrincipalId')))]"); + assert.deepEqual(Object.keys(rbac.parameters).filter(name => name.endsWith('PrincipalId')).sort(), ['dabPrincipalId', 'gatewayPrincipalId']); + assert.equal(rbac.resources.length, 1, 'AcrPull for the two app identities is the only RBAC write'); + const workspace = template.resources.find(resource => resource.type === 'Microsoft.OperationalInsights/workspaces'); + assert.equal(workspace.properties.workspaceCapping.dailyQuotaGb, "[json('0.1')]"); }); diff --git a/tests/setup-journey.test.mjs b/tests/setup-journey.test.mjs index 34acaf7..c67b352 100644 --- a/tests/setup-journey.test.mjs +++ b/tests/setup-journey.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm, mkdir, writeFile, realpath } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { runtimeFor, initializeWorkspace } from '../src/workspace.mjs'; @@ -15,7 +15,7 @@ try { setup = await import('../scripts/setup.mjs'); } catch (error) { if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; } async function fixture(t, selected = true) { - const home = await mkdtemp(join(tmpdir(), 'sql-apps-journey-')); + const home = await realpath(await mkdtemp(join(tmpdir(), 'sql-apps-journey-'))); t.after(() => rm(home, { recursive: true, force: true })); if (selected) await initializeWorkspace(home, 30000, async () => 'free'); return { @@ -53,8 +53,7 @@ test('prerequisites ready never means workspace selected or launch approved', as assert.match(report.next.nextAction, new RegExp(`setup run --profile role-based-data --workspace ${suggested}\\b`)); assert.doesNotMatch(report.next.nextAction, /workspace-plan/, 'workspace-plan needs a current build; setup must be usable before it'); const launch = report.stages.find(stage => stage.id === 'launch'); - const startup = options.profile === 'role-based-data' ? 'role-based-app' : 'app'; - assert.match(launch.nextAction, new RegExp(`${startup} starts under the same setup approval`), 'the launch stage must say the startup is covered by the one approval'); + assert.match(launch.nextAction, new RegExp(`app starts under the same setup approval`), 'the launch stage must say the startup is covered by the one approval'); }); test('a missing role-based configuration blocks launch with guidance instead of crashing consent planning', async t => { @@ -70,7 +69,7 @@ test('a missing role-based configuration blocks launch with guidance instead of assert.equal(launch.status, 'blocked'); assert.equal(launch.code, 'ROLE_BASED_CONFIG_MISSING'); assert.match(launch.nextAction, /^role-based-data\.json is not configured yet\. .*role-based-data\.example\.json/); - assert.equal(requested.includes('role-based-app'), false, 'startup consent must not be planned without the profile configuration'); + assert.equal(requested.includes('app'), false, 'startup consent must not be planned without the profile configuration'); assert.equal(report.stages.find(stage => stage.id === 'prerequisites').status, 'done'); } }); @@ -199,7 +198,7 @@ test('setup passes its reviewed authentication environment to the attached launc }, launch: async (root, operation, container, selected, profile, fetcher, injected) => { assert.equal(root, home); - assert.equal(operation, 'role-based-app'); + assert.equal(operation, 'app'); assert.equal(container, undefined); assert.equal(selected.id, runtime.id); assert.equal(profile, options.profile); @@ -235,24 +234,24 @@ test('setup reuses the explicitly selected external SQL container without launch }); for (const health of [ - { name: 'absent worker', status: 503, body: { status: 'not-ready', dependency: 'functions' } }, - { name: 'contradictory success', status: 200, body: { status: 'not-ready', dependency: 'queue' } }, + { name: 'absent worker', status: 503, body: { status: 'not-ready', dependency: 'data' } }, + { name: 'contradictory success', status: 200, body: { status: 'not-ready', dependency: 'data' } }, { name: 'arbitrary success JSON', status: 200, body: {} }, { name: 'wrong readiness state', status: 200, body: { status: 'live' } }, { name: 'malformed JSON', status: 200, body: 'not-json' }, ]) { - test(`matching unready foundation (${health.name}) is blocked without automatic relaunch`, async t => { + test(`matching unready gateway (${health.name}) is blocked without automatic relaunch`, async t => { const { home, deps } = await fixture(t); const runtime = runtimeFor(undefined, home); let executions = 0; let launches = 0; - const report = await setup.runSetup(home, { profile: 'foundation' }, { + const report = await setup.runSetup(home, { profile: 'role-based-data' }, { ...deps, check: async () => ({ checks: [{ required: true, status: 'action-required', code: 'EXISTING_GATEWAY_CHECK_OWNERSHIP' }] }), consent: async (_, request) => ({ allowed: true, request }), fetch: async url => String(url).endsWith('/local/workspace') ? Response.json({ version: 1, id: runtime.id, mode: runtime.mode, origins: runtime.origins, - profile: 'foundation', container: runtime.sqlContainer, sourceFingerprint: 'fixture-build', authentication: { mode: 'preview' } }) + profile: 'role-based-data', container: runtime.sqlContainer, sourceFingerprint: 'fixture-build', authentication: { mode: 'preview' } }) : typeof health.body === 'string' ? new Response(health.body, { status: health.status }) : Response.json(health.body, { status: health.status }), execute: async () => { executions++; }, @@ -280,15 +279,15 @@ test('a reachable unready gateway does not trigger restores or builds before its const { home, deps } = await fixture(t); const runtime = runtimeFor(undefined, home); let commands = 0; - const report = await setup.runSetup(home, { profile: 'foundation' }, { + const report = await setup.runSetup(home, { profile: 'role-based-data' }, { ...deps, source: async () => ({ ready: true, fingerprint: 'fixture-build', build: { ready: false, code: 'BUILD_UNVERIFIED' } }), dependencies: async () => ({ ready: false }), consent: async (_, request) => ({ allowed: true, request }), fetch: async url => String(url).endsWith('/local/workspace') ? Response.json({ version: 1, id: runtime.id, mode: runtime.mode, origins: runtime.origins, - profile: 'foundation', container: runtime.sqlContainer, sourceFingerprint: 'fixture-build', authentication: { mode: 'preview' } }) - : Response.json({ status: 'not-ready', dependency: 'functions' }, { status: 503 }), + profile: 'role-based-data', container: runtime.sqlContainer, sourceFingerprint: 'fixture-build', authentication: { mode: 'preview' } }) + : Response.json({ status: 'not-ready', dependency: 'data' }, { status: 503 }), execute: async () => { commands++; }, progress: () => {}, }); @@ -326,7 +325,7 @@ test('external SQL startup guidance reviews the selected target instead of the o const { home, deps, options } = await fixture(t); const report = await setup.planSetup(home, { ...options, container: 'external-sql' }, deps); const text = setup.renderSetup(report).join('\n'); - assert.match(text, /consent-plan role-based-app external-sql/); + assert.match(text, /consent-plan app external-sql/); assert.doesNotMatch(text, /setup approval --profile/, 'the setup bundle cannot approve external SQL selection'); }); @@ -392,7 +391,7 @@ test('setup requires explicit supported capability profile and rejects cloud or assert.deepEqual(setup.parseSetupArguments(['plan', '--profile', 'role-based-data', '--json']), { command: 'plan', options: { profile: 'role-based-data', json: true } }); assert.throws(() => setup.parseSetupArguments(['run', '--profile', 'cloud'])); - assert.throws(() => setup.parseSetupArguments(['run', '--profile', 'foundation', '--profile', 'foundation'])); + assert.throws(() => setup.parseSetupArguments(['run', '--profile', 'role-based-data', '--profile', 'role-based-data'])); assert.throws(() => setup.parseSetupArguments(['install', 'relative.json'])); }); @@ -440,9 +439,9 @@ test('post-launch verification failure stops the attached child before reporting test('public setup planning rejects unsupported options before probing or selecting resources', async t => { const { home, deps } = await fixture(t); - await assert.rejects(setup.planSetup(home, { profile: 'foundation', container: '--invalid' }, deps), /Invalid/); - await assert.rejects(setup.planSetup(home, { profile: 'foundation', workspace: '30001' }, deps), /aligned/); - await assert.rejects(setup.planSetup(home, { profile: 'foundation', deploy: true }, deps), /Unknown/); + await assert.rejects(setup.planSetup(home, { profile: 'role-based-data', container: '--invalid' }, deps), /Invalid/); + await assert.rejects(setup.planSetup(home, { profile: 'role-based-data', workspace: '30001' }, deps), /aligned/); + await assert.rejects(setup.planSetup(home, { profile: 'role-based-data', deploy: true }, deps), /Unknown/); }); test('approved restore and build re-plan readiness and skip already verified npm dependencies', async t => { @@ -517,8 +516,8 @@ test('human setup output states completed results and names the checks behind a const summary = setup.renderSetup({ stages: [workspace], next: workspace }).at(-1); assert.match(summary, /^Next: workspace/); assert.match(summary, /consent-plan workspace-init 21240/, 'the final next step must say what to approve, not only the effect'); - const bundled = setup.renderSetup({ profile: 'foundation', stages: [workspace], next: workspace }).at(-1); - assert.match(bundled, /setup approval --profile foundation --workspace 21240/, 'profile reports point at the single approval'); + const bundled = setup.renderSetup({ profile: 'role-based-data', stages: [workspace], next: workspace }).at(-1); + assert.match(bundled, /setup approval --profile role-based-data --workspace 21240/, 'profile reports point at the single approval'); }); async function processFixture(t, outcome, container) { @@ -539,7 +538,7 @@ const server = createServer((request, response) => { const unready = ${JSON.stringify(outcome === 'unready')}; response.statusCode = request.url === '/health/ready' && unready ? 503 : 200; response.end(JSON.stringify(request.url === '/local/workspace' ? identity : - unready ? { status: 'not-ready', dependency: 'functions' } : { status: 'ready' })); + unready ? { status: 'not-ready', dependency: 'data' } : { status: 'ready' })); }); server.listen(0, '127.0.0.1', () => { writeFileSync('endpoint.txt', 'http://127.0.0.1:' + server.address().port); diff --git a/tests/setup.test.mjs b/tests/setup.test.mjs index ac5f891..6a2aa97 100644 --- a/tests/setup.test.mjs +++ b/tests/setup.test.mjs @@ -70,7 +70,7 @@ test('pre-build diagnostics check proposed workspace ports instead of an occupie return port === 18080 ? 'occupied' : 'free'; } }); const report = await checkSetup({ root, workspace: '30000' }, deps); - assert.deepEqual(checked, [30000, 30001, 30003, 30004, 30002]); + assert.deepEqual(checked, [30000, 30001]); assert.equal(report.ready, true); assert.equal(report.workspace.mode, 'isolated'); assert.deepEqual(parseArguments(['--workspace', '30000']), { workspace: '30000' }); @@ -122,7 +122,7 @@ test('role-based-data reports its application configuration without blocking too assert.equal(unimplemented.code, 'ROLE_BASED_PROCEDURE_MISSING', 'a DAB procedure absent from sql/ must be caught before SQL startup consent'); assert.match(unimplemented.message, /dbo\.Ready/); assert.match(unimplemented.nextAction, /sql\//); - assert.equal((await checkSetup({}, base)).checks.some(c => c.id === 'role-based-config'), false); + assert.equal((await checkSetup({}, base)).checks.some(c => c.id === 'role-based-config'), true, 'the only profile is always checked'); const actions = (await checkSetup({}, fixture({ exists: async () => false }))).checks .filter(c => ['dependencies', 'build'].includes(c.id)).map(c => c.nextAction).join(' '); assert.doesNotMatch(actions, /run npm (ci|run build)/, 'raw restore/build advice bypasses the guarded consent-run path'); @@ -323,9 +323,6 @@ for (const selection of [undefined, 'legacy', 33000]) { deps.port = async () => 'occupied'; const containers = [ { owner: 'data', workspace: runtime.id, ports: `127.0.0.1:${runtime.ports.data}->5000/tcp` }, - { owner: 'storage', workspace: runtime.id, - ports: `127.0.0.1:${runtime.ports.blob}->10000/tcp, 127.0.0.1:${runtime.ports.queue}->10001/tcp` }, - { owner: 'functions', workspace: runtime.id, ports: `127.0.0.1:${runtime.ports.functions}->80/tcp` }, ]; deps.run = (cmd, args) => { if (args[0] !== 'ps') return run(cmd, args); @@ -333,7 +330,7 @@ for (const selection of [undefined, 'legacy', 33000]) { return Promise.resolve(containers.map(container => JSON.stringify(container)).join('\n')); }; report = await checkSetup({ root }, deps); - assert.equal(report.checks.filter(c => c.code === 'OWNED_SERVICE_RUNNING').length, 4); + assert.equal(report.checks.filter(c => c.code === 'OWNED_SERVICE_RUNNING').length, 1); assert.equal(report.ready, false, 'Owned dependencies do not prove gateway ownership'); deps.port = async () => 'unknown'; assert.equal((await checkSetup({ root }, deps)).ready, false); diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts deleted file mode 100644 index e6c2758..0000000 --- a/tests/smoke.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { smoke } from '../src/smoke.js'; - -const origin = 'https://gateway.example.com'; -const functionsUrl = 'https://functions.example.com'; -const identities: Record = { - 'user-token': { oid: '11111111-1111-1111-1111-111111111111', tenantId: 'tenant' }, - 'other-token': { oid: '22222222-2222-2222-2222-222222222222', tenantId: 'tenant' }, -}; - -interface Recorded { method: string; path: string; token?: string } - -async function failure(promise: Promise): Promise { - try { await promise; } catch (error) { - if (error instanceof AggregateError) return error.errors.map(inner => String(inner)).join('\n'); - return String(error); - } - throw new Error('Expected the smoke to fail'); -} - -function environment(overrides: { - rows?: (token: string) => unknown; - graphql?: (token: string) => unknown; - anonymous?: (path: string) => number; - crossUser?: number; -} = {}) { - const calls: Recorded[] = []; - const files = new Map(); - const fetcher: typeof fetch = async (input, init) => { - const url = new URL(String(input)); - const headers = new Headers(init?.headers); - const token = headers.get('authorization')?.replace('Bearer ', ''); - const method = init?.method ?? 'GET'; - const path = url.pathname + url.search; - calls.push({ method, path, ...(token ? { token } : {}) }); - const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); - if (url.origin === functionsUrl) return new Response('', { status: 401 }); - if (!token) return json({ error: 'unauthorized' }, overrides.anonymous?.(url.pathname) ?? 401); - const identity = identities[token]; - if (!identity) return json({ error: 'unauthorized' }, 401); - if (url.pathname === '/auth/me') return json(identity); - if (url.pathname === '/api/FileJob') return json(overrides.rows?.(token) ?? { value: [] }); - if (url.pathname === '/graphql') return json(overrides.graphql?.(token) ?? { data: { fileJobs: { items: [] } } }); - if (url.pathname === '/functions/echo') { - return json({ result: { user: identity, input: JSON.parse(String(init?.body ?? '{}')) } }); - } - if (url.pathname.startsWith('/storage/')) { - const name = decodeURIComponent(url.pathname.slice('/storage/'.length)); - const owner = files.has(name); - if (token !== 'user-token' && owner) return json({ error: 'denied' }, overrides.crossUser ?? 403); - if (method === 'PUT') { files.set(name, String(init?.body ?? '')); return new Response(null, { status: 204 }); } - if (!owner) return json({ error: 'missing' }, 404); - if (method === 'DELETE') { files.delete(name); return new Response(null, { status: 204 }); } - return new Response('private acceptance data', { status: 200 }); - } - return json({ error: 'not found' }, 404); - }; - return { calls, files, fetcher }; -} - -test('deployed smoke exercises the data path, files, functions, and anonymous rejection', async () => { - const { calls, files, fetcher } = environment(); - await smoke(origin, 'user-token', 'other-token', fetcher, functionsUrl); - assert.equal(files.size, 0, 'the smoke must clean up its own fixture file'); - const dataReads = calls.filter(call => call.path.startsWith('/api/FileJob') && call.token); - assert.deepEqual(dataReads.map(call => call.token).sort(), ['other-token', 'user-token']); - assert.ok(dataReads.every(call => call.path === '/api/FileJob?$first=5')); - assert.equal(calls.filter(call => call.path === '/graphql' && call.token).length, 2); - assert.ok(calls.some(call => call.path === '/api/FileJob' && !call.token)); - assert.ok(calls.some(call => call.path === '/graphql' && !call.token)); - assert.ok(calls.some(call => call.path === '/auth/me' && !call.token)); -}); - -test('deployed smoke fails when the data path leaks another owner', async () => { - const { fetcher } = environment({ - rows: () => ({ value: [{ id: 'row', owner_oid: '33333333-3333-3333-3333-333333333333' }] }), - }); - assert.match(await failure(smoke(origin, 'user-token', 'other-token', fetcher, functionsUrl)), /another user's row/); -}); - -test('deployed smoke fails when the data path exposes an excluded field or GraphQL errors', async () => { - for (const overrides of [ - { rows: () => ({ value: [{ id: 'row', blob_key: 'secret' }] }) }, - { graphql: () => ({ errors: [{ message: 'denied' }], data: { fileJobs: { items: [] } } }) }, - { graphql: () => ({ data: {} }) }, - ]) { - const { fetcher } = environment(overrides); - await failure(smoke(origin, 'user-token', 'other-token', fetcher, functionsUrl)); - } -}); - -test('deployed smoke fails when anonymous callers reach the data path', async () => { - for (const path of ['/api/FileJob', '/graphql']) { - const { fetcher } = environment({ anonymous: requested => (requested === path ? 200 : 401) }); - assert.match(await failure(smoke(origin, 'user-token', 'other-token', fetcher, functionsUrl)), - new RegExp(`Anonymous ${path} request returned 200`)); - } -}); - -test('deployed smoke requires two distinct users in one tenant', async () => { - const { fetcher } = environment(); - await assert.rejects(smoke(origin, 'user-token', 'user-token', fetcher, functionsUrl), - /Two different verified users/); -}); diff --git a/tests/storage.test.ts b/tests/storage.test.ts deleted file mode 100644 index 23d2a84..0000000 --- a/tests/storage.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { createServer } from 'node:http'; -import { once } from 'node:events'; -import { connectionFileStore } from '../src/storage.js'; - -test('Blob SDK listing reads one bounded page and preserves its opaque continuation', async t => { - const prefix = 'fixture-tenant/fixture-user/'; - const continuationToken = 'opaque + / marker'; - const requests: URL[] = []; - const blob = (index: number) => `${prefix}file-${index}.txt${index}BlockBlob`; - const server = createServer((request, response) => { - const url = new URL(request.url!, 'http://fixture.invalid'); - requests.push(url); - const second = url.searchParams.get('marker') === continuationToken; - const entries = second ? blob(101) : Array.from({ length: 100 }, (_, index) => blob(index + 1)).join(''); - response.writeHead(200, { 'content-type': 'application/xml', 'x-ms-request-id': 'fixture-request', 'x-ms-version': '2025-11-05' }); - response.end(` - - ${prefix}/100 - ${entries}${second ? `${prefix}.snapshots/` : ''} - ${second ? '' : continuationToken} - `); - }); - server.listen(0, '127.0.0.1'); - await once(server, 'listening'); - t.after(() => new Promise((resolve, reject) => { - server.closeAllConnections(); - server.close(error => error ? reject(error) : resolve()); - })); - const address = server.address(); - assert.ok(address && typeof address !== 'string'); - const endpoint = `http://127.0.0.1:${address.port}/fixturestorage`; - const key = Buffer.alloc(32).toString('base64'); - const store = connectionFileStore(`DefaultEndpointsProtocol=http;AccountName=fixturestorage;AccountKey=${key};BlobEndpoint=${endpoint}`, 'files'); - assert.ok(store.list); - const first = await store.list(prefix, { maxPageSize: 100 }); - assert.deepEqual(first.files, Array.from({ length: 100 }, (_, index) => ({ name: `file-${index + 1}.txt`, size: index + 1 }))); - assert.equal(first.continuationToken, continuationToken); - assert.equal(requests.length, 1, 'listing must not prefetch the second page'); - const second = await store.list(prefix, { maxPageSize: 100, continuationToken: first.continuationToken }); - assert.deepEqual(second, { files: [{ name: 'file-101.txt', size: 101 }] }); - assert.equal(requests.length, 2); - assert.equal(requests[1]!.searchParams.get('marker'), continuationToken); - for (const request of requests) { - assert.equal(request.pathname, '/fixturestorage/files'); - assert.equal(request.searchParams.get('comp'), 'list'); - assert.equal(request.searchParams.get('prefix'), prefix); - assert.equal(request.searchParams.get('delimiter'), '/'); - assert.equal(request.searchParams.get('maxresults'), '100'); - } -}); diff --git a/tests/tooling-package.test.mjs b/tests/tooling-package.test.mjs index 9d4637b..e9ae198 100644 --- a/tests/tooling-package.test.mjs +++ b/tests/tooling-package.test.mjs @@ -1,6 +1,6 @@ import { execFile } from 'node:child_process'; import assert from 'node:assert/strict'; -import { copyFile, lstat, mkdtemp, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { copyFile, lstat, mkdtemp, mkdir, readFile, readdir, rename, rm, symlink, writeFile, realpath } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -76,7 +76,7 @@ test('stages publishable package metadata with synchronized exact runtime depend for (const candidate of [source, pkg]) { assert.equal(candidate.name, '@microsoft/sql-apps'); - assert.equal(candidate.version, '0.1.2'); + assert.equal(candidate.version, '0.2.0'); assert.equal(candidate.type, 'module'); assert.deepEqual(candidate.bin, { 'sql-apps': 'bin/sql-apps.mjs' }); assert.equal(Object.hasOwn(candidate, 'private'), false); @@ -150,7 +150,7 @@ test('bin help is project-independent and its validated command map targets pack }); test('bin dispatches from the project root when invoked in a nested directory', async t => { - const fixture = await mkdtemp(join(tmpdir(), 'sql-apps-bin-project-')); + const fixture = await realpath(await mkdtemp(join(tmpdir(), 'sql-apps-bin-project-'))); t.after(() => rm(fixture, { recursive: true, force: true })); const packageRoot = join(fixture, 'package'); const project = join(fixture, 'project'); @@ -179,7 +179,7 @@ test('bin dispatches from the project root when invoked in a nested directory', starter: 'data', profile: 'role-based-data', scaffoldVersion: 1, - tooling: { package: '@microsoft/sql-apps', version: '0.1.2' }, + tooling: { package: '@microsoft/sql-apps', version: '0.2.0' }, })}\n`), ]); diff --git a/tests/tooling-project-runtime.test.mjs b/tests/tooling-project-runtime.test.mjs index 7826d6c..d4ae70b 100644 --- a/tests/tooling-project-runtime.test.mjs +++ b/tests/tooling-project-runtime.test.mjs @@ -27,7 +27,7 @@ async function run(command, args, cwd) { async function projectFixture(t) { const directory = await mkdtemp(join(tmpdir(), 'sql-apps-generated-')); - t.after(() => rm(directory, { recursive: true, force: true })); + t.after(() => rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 })); const project = join(directory, 'customer-data'); const tooling = join(project, 'node_modules', '@microsoft', 'sql-apps'); await Promise.all([ @@ -276,6 +276,7 @@ test('packaged deployment resolves immutable assets from tooling while retaining databaseName: { value: 'app' }, dabPrincipalId: { value: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, gatewayPrincipalId: { value: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' }, + functionPrincipalId: { value: '' }, gatewayName: { value: 'gateway' }, gatewayUrl: { value: 'https://example.invalid' }, functionsName: { value: 'functions' }, diff --git a/tests/workspace-runtime.test.mjs b/tests/workspace-runtime.test.mjs index 97d13d4..52ccea6 100644 --- a/tests/workspace-runtime.test.mjs +++ b/tests/workspace-runtime.test.mjs @@ -3,6 +3,8 @@ import assert from 'node:assert/strict'; import { mkdtemp, readFile, writeFile, rm, mkdir, realpath } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; const load = () => import('../src/workspace.mjs'); async function home(t) { @@ -11,6 +13,17 @@ async function home(t) { return realpath(root); } +test('Windows short paths use the same canonical workspace identity', { skip: process.platform !== 'win32' }, async t => { + const { stdout } = await promisify(execFile)(process.env.ComSpec, + ['/d', '/c', `for %I in ("${process.env.ProgramFiles}") do @echo %~sI`], { windowsVerbatimArguments: true }); + const alias = stdout.trim(); + const canonical = await realpath(alias); + if (alias === canonical) return t.skip('8.3 paths are unavailable on this filesystem'); + const { describeWorkspace, runtimeFor } = await load(); + assert.deepEqual(describeWorkspace(alias, 30000), describeWorkspace(canonical, 30000)); + assert.deepEqual(runtimeFor(undefined, alias), runtimeFor(undefined, canonical)); +}); + test('workspace candidates derive distinct ports, names and state from canonical home', async t => { const first = await home(t); const second = await home(t); @@ -23,17 +36,17 @@ test('workspace candidates derive distinct ports, names and state from canonical const one = runtimeFor(undefined, first); const two = runtimeFor(undefined, second); assert.notEqual(one.defaultSql, two.defaultSql); - assert.notEqual(one.names.network, two.names.network); + assert.notEqual(one.names.data, two.names.data); assert.notEqual(one.stateDirectory, two.stateDirectory); assert.notEqual(one.database, two.database); assert.notEqual(one.login, two.login); - assert.equal(new Set(Object.values(one.ports)).size, 6); + assert.deepEqual(one.ports, { gateway: a.descriptor.basePort, data: a.descriptor.basePort + 1, sql: a.descriptor.basePort + 5 }, + 'fixed offsets keep existing isolated workspaces on the same SQL port'); assert.equal(one.origins.app, `http://127.0.0.1:${one.ports.gateway}`); assert.equal(one.defaultSql, `sql-apps-${one.id}-sql`); assert.equal(one.database, `sql_apps_local_${one.id}`); assert.equal(one.login, `sql_apps_local_dab_${one.id}`); assert.equal(one.stateDirectory, join(first, '.sql-apps', 'workspaces', one.id)); - assert.equal(one.imageRepository, `sql-apps-${one.id}-functions`); assert.deepEqual(one.labels, ['--label', `sql-apps.workspace=${one.id}`]); assert.deepEqual(runtimeFor(undefined, first), one); }); @@ -42,8 +55,8 @@ test('occupied deterministic candidate produces a free alternate proposal withou const root = await home(t); const { proposeWorkspace, workspaceFile } = await load(); let checks = 0; - const report = await proposeWorkspace(root, async () => ++checks <= 6 ? 'occupied' : 'free'); - assert.equal(report.conflicts.length, 6); + const report = await proposeWorkspace(root, async () => ++checks <= 3 ? 'occupied' : 'free'); + assert.equal(report.conflicts.length, 3); assert.ok(report.alternative); assert.notEqual(report.descriptor.basePort, report.alternative.basePort); await assert.rejects(readFile(workspaceFile(root)), { code: 'ENOENT' }); diff --git a/tests/workspace.test.mjs b/tests/workspace.test.mjs index 5879179..20c5d4d 100644 --- a/tests/workspace.test.mjs +++ b/tests/workspace.test.mjs @@ -19,7 +19,7 @@ async function fixture(t) { await writeFile(join(home, 'package.json'), JSON.stringify({ name: 'fixture', version: '0.1.0', sqlApps: { project: true }, scripts: { local: 'sql-apps local' }, - dependencies: { '@microsoft/sql-apps': '0.1.2' }, + dependencies: { '@microsoft/sql-apps': '0.2.0' }, })); await writeFile(join(home, 'package-lock.json'), JSON.stringify({ lockfileVersion: 3 })); await writeFile(join(home, 'tsconfig.json'), JSON.stringify({ compilerOptions: {} })); @@ -32,7 +32,7 @@ async function fixture(t) { starter: 'data', profile: 'role-based-data', scaffoldVersion: 1, - tooling: { package: '@microsoft/sql-apps', version: '0.1.2' }, + tooling: { package: '@microsoft/sql-apps', version: '0.2.0' }, })); await writeFile(join(home, 'application.json'), JSON.stringify({ name: 'fixture', selectedExamples: [] })); await writeFile(join(home, 'role-based-data.json'), JSON.stringify({ diff --git a/tsconfig.json b/tsconfig.json index 4dfc601..663141f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,5 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, - "include": ["src/**/*.ts", "tests/**/*.ts", "functions/src/**/*.ts", "examples/*/tests/**/*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts", "examples/*/tests/**/*.ts"] } diff --git a/tsconfig.runtime.json b/tsconfig.runtime.json index 321df61..6c2486c 100644 --- a/tsconfig.runtime.json +++ b/tsconfig.runtime.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.json", - "include": ["src/**/*.ts", "functions/src/**/*.ts"], + "include": ["src/**/*.ts"], "exclude": ["tests", "examples"] }