diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 110d529..850f769 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -49,11 +49,13 @@ jobs:
# Hard resource ceiling so a runaway test-storybook (one headless Chromium per Jest
# worker) can't OOM the runner host — it fails its own container instead. The real cap
# is `--maxWorkers=2` in test-storybook:ci; this is the belt-and-suspenders guardrail.
+ # 6g (was 4g): build-storybook alone (compodoc + full Angular AOT build) measured ~5.8GB
+ # peak RSS locally, leaving too little headroom at 4g.
# NB: requires the Gitea act_runner to allow container jobs (docker mode). If the runner
# is host-only, drop this `container:` block and rely on the worker cap alone.
container:
image: node:24-bookworm
- options: --cpus=2 --memory=4g --memory-swap=4g
+ options: --cpus=2 --memory=6g --memory-swap=6g
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
diff --git a/backend/src/BigRegister.Api/Data/Migrations/20260729071227_ZaakAndDrcUrls.cs b/backend/src/BigRegister.Api/Data/Migrations/20260729071227_ZaakAndDrcUrls.cs
index 321b3a6..cdb00a8 100644
--- a/backend/src/BigRegister.Api/Data/Migrations/20260729071227_ZaakAndDrcUrls.cs
+++ b/backend/src/BigRegister.Api/Data/Migrations/20260729071227_ZaakAndDrcUrls.cs
@@ -1,38 +1,38 @@
-using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
+ ///
+ public partial class ZaakAndDrcUrls : Migration
+ {
///
- public partial class ZaakAndDrcUrls : Migration
+ protected override void Up(MigrationBuilder migrationBuilder)
{
- ///
- protected override void Up(MigrationBuilder migrationBuilder)
- {
- migrationBuilder.AddColumn(
- name: "DrcUrl",
- table: "Documents",
- type: "TEXT",
- nullable: true);
+ migrationBuilder.AddColumn(
+ name: "DrcUrl",
+ table: "Documents",
+ type: "TEXT",
+ nullable: true);
- migrationBuilder.AddColumn(
- name: "ZaakUrl",
- table: "Applications",
- type: "TEXT",
- nullable: true);
- }
-
- ///
- protected override void Down(MigrationBuilder migrationBuilder)
- {
- migrationBuilder.DropColumn(
- name: "DrcUrl",
- table: "Documents");
-
- migrationBuilder.DropColumn(
- name: "ZaakUrl",
- table: "Applications");
- }
+ migrationBuilder.AddColumn(
+ name: "ZaakUrl",
+ table: "Applications",
+ type: "TEXT",
+ nullable: true);
}
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "DrcUrl",
+ table: "Documents");
+
+ migrationBuilder.DropColumn(
+ name: "ZaakUrl",
+ table: "Applications");
+ }
+ }
}
diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs
index 2585299..d161cda 100644
--- a/backend/src/BigRegister.Api/Program.cs
+++ b/backend/src/BigRegister.Api/Program.cs
@@ -1,3 +1,5 @@
+using System.Security.Cryptography;
+using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using BigRegister.Api.Contracts;
@@ -353,6 +355,36 @@ api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ct
.Produces>()
.ProducesProblem(StatusCodes.Status403Forbidden);
+// OpenZaak's Notificaties API (NRC) calls this on every zaak event once an `abonnement` is
+// provisioned (WP-52, out-of-band — see openzaak-integration.md, no app code subscribes it).
+// The caller is NRC, not a user: no Principal, so this audits via AuthzAuditStore directly
+// rather than the Principal-shaped AuditAuthz helper below. A plain shared secret (not a
+// JWT — that's only for this BFF's OUTBOUND ZGW calls) compared in fixed time; an unconfigured
+// secret always rejects.
+api.MapPost("/zgw/notificaties", (HttpContext ctx, NotificatieDto body) =>
+{
+ var expected = zgw.NotificatieAuthorization;
+ var actual = ctx.Request.Headers.Authorization.ToString();
+ var allowed = !string.IsNullOrEmpty(expected)
+ && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(actual), Encoding.UTF8.GetBytes(expected));
+
+ var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none";
+ app.Logger.LogInformation(
+ "authz action={Action} resource={Resource} decision={Decision} role={Role} correlationId={Cid}",
+ "zgw:notificatie", body.HoofdObject, allowed ? "allow" : "deny", "nrc", cid);
+ AuthzAuditStore.Record("zgw:notificatie", body.HoofdObject, allowed, "nrc", cid);
+
+ if (!allowed) return Results.Unauthorized();
+ // ponytail: nothing to invalidate — /admin/cases above already reads IZaakSource fresh
+ // every call, no cache exists anywhere in this backend. Add real invalidation here if/when
+ // one is introduced; today a valid notification's only effect is the audit trail proving
+ // the webhook round-trip works.
+ return Results.NoContent();
+})
+// NRC calls this directly, not the FE — same "hand-written, no client codegen" seam as
+// /uploads and /brief/reveal-bignummer.
+.ExcludeFromDescription();
+
// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing
// DELETE /applications/{id}. A missing id is a 404.
api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () =>
diff --git a/backend/src/BigRegister.Api/Zgw/NotificatieDto.cs b/backend/src/BigRegister.Api/Zgw/NotificatieDto.cs
new file mode 100644
index 0000000..ba51dc8
--- /dev/null
+++ b/backend/src/BigRegister.Api/Zgw/NotificatieDto.cs
@@ -0,0 +1,19 @@
+using System.Text.Json.Serialization;
+
+namespace BigRegister.Api.Zgw;
+
+///
+/// The Notificaties API (NRC) webhook body (WP-52) — the standard ZGW notification shape POSTed
+/// to a subscribed abonnement's callbackUrl on every zaak event. Only
+/// (the zaak's URL — not PII) is read today, for the audit trail; the
+/// rest is parsed because it's the real payload shape a live OpenZaak actually sends, not
+/// because this endpoint acts on it yet.
+///
+public sealed record NotificatieDto(
+ [property: JsonPropertyName("kanaal")] string Kanaal,
+ [property: JsonPropertyName("hoofdObject")] string HoofdObject,
+ [property: JsonPropertyName("resource")] string Resource,
+ [property: JsonPropertyName("resourceUrl")] string ResourceUrl,
+ [property: JsonPropertyName("actie")] string Actie,
+ [property: JsonPropertyName("aanmaakdatum")] DateTimeOffset Aanmaakdatum,
+ [property: JsonPropertyName("kenmerken")] Dictionary? Kenmerken);
diff --git a/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs b/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs
index 8011e9f..b525a87 100644
--- a/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs
+++ b/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs
@@ -8,8 +8,9 @@ namespace BigRegister.Api.Zgw;
///
/// The ZGW standard is FIVE separate services, each its own base URL — slice 1 (WP-49) only
/// needed the Zaken API (ZRC) and, to resolve human labels for a zaaktype, the Catalogi API
-/// (ZTC). WP-50 (create-zaak) stayed on those two; WP-51 adds the Documenten API (DRC).
-/// BRC/NRC arrive with later slices (WP-52+).
+/// (ZTC). WP-50 (create-zaak) stayed on those two; WP-51 adds the Documenten API (DRC); WP-52
+/// adds the Notificaties API (NRC) — inbound only, see .
+/// BRC arrives with a later slice, if ever.
///
public sealed class ZgwOptions
{
@@ -52,4 +53,16 @@ public sealed class ZgwOptions
/// URL (Catalogi), so create-document (WP-51) knows which type to register per category —
/// the document analogue of .
public Dictionary InformatieobjecttypeUrls { get; init; } = new();
+
+ /// Notificaties API (NRC) base URL (WP-52) — documentation/provisioning only, no
+ /// code in this app calls it: subscribing an abonnement is a one-time out-of-band
+ /// step (see openzaak-integration.md), not something the BFF does at runtime.
+ public string NrcBaseUrl { get; init; } = "";
+
+ /// The exact Authorization header value NRC must send on every
+ /// POST /zgw/notificaties callback (WP-52) — a plain shared secret set into the
+ /// abonnement's auth field when provisioning, not a JWT. Empty (the default)
+ /// means every notification is rejected — an unconfigured secret must never mean "accept
+ /// anything".
+ public string NotificatieAuthorization { get; init; } = "";
}
diff --git a/e2e/brief-v2.spec.ts b/e2e/brief-v2.spec.ts
index 23241bf..0eefca4 100644
--- a/e2e/brief-v2.spec.ts
+++ b/e2e/brief-v2.spec.ts
@@ -33,13 +33,15 @@ test('drafter composes → approver sends; admin republishes appearance', async
await expect(page.getByText('Concept opgeslagen')).toBeVisible({ timeout: 10_000 });
// --- Preview: draft is watermarked ---
- await page.getByRole('button', { name: 'Voorbeeld' }).click();
+ await page.getByRole('button', { name: 'Voorbeeld', exact: true }).click();
const [draftPreview] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/v1/brief/preview'), { timeout: 10_000 }),
page.getByRole('button', { name: 'Openen als document (PDF)' }).click(),
]);
expect(draftPreview.headers()['content-type']).toContain('text/html');
- expect(await draftPreview.text()).toContain('preview-watermark');
+ // The `.preview-watermark` CSS rule ships in every preview (draft or sent) — only the
+ // "VOORBEELD" marker div (LetterHtml.Render's `watermark` param) is actually conditional.
+ expect(await draftPreview.text()).toContain('>VOORBEELD<');
await page.getByRole('button', { name: 'Sluiten' }).click();
// --- Submit → approve → send (role change = full navigation, per WP-33 stickiness) ---
@@ -52,13 +54,16 @@ test('drafter composes → approver sends; admin republishes appearance', async
await expect(page.getByText('De brief is verzonden.')).toBeVisible();
// --- Preview: sent letter serves its frozen, unwatermarked archive ---
- await page.getByRole('button', { name: 'Voorbeeld' }).click();
+ // Sent = !canEdit → app-letter-composer, whose "Voorbeeld" click goes straight to
+ // store.previewLetter() (fetch + window.open), unlike the drafter's behandel-scherm
+ // dialog above where "Voorbeeld" only opens a local modal and a second click inside it
+ // triggers the fetch.
const [sentPreview] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/v1/brief/preview'), { timeout: 10_000 }),
- page.getByRole('button', { name: 'Openen als document (PDF)' }).click(),
+ page.getByRole('button', { name: 'Voorbeeld', exact: true }).click(),
]);
expect(sentPreview.headers()['content-type']).toContain('text/html');
- expect(await sentPreview.text()).not.toContain('preview-watermark');
+ expect(await sentPreview.text()).not.toContain('>VOORBEELD<');
// --- Admin republishes the appearance ---
await page.goto('/brief/huisstijl?role=admin');
@@ -80,7 +85,7 @@ test('drafter composes → approver sends; admin republishes appearance', async
// --- Drafter's canvas reflects the new appearance on a fresh letter ---
await page.goto('/brief?role=drafter');
await page.getByRole('button', { name: 'Opnieuw beginnen (demo)' }).click();
- await page.getByRole('button', { name: 'Voorbeeld' }).click();
+ await page.getByRole('button', { name: 'Voorbeeld', exact: true }).click();
await expect(page.locator('dialog')).toContainText(unique);
await page.getByRole('button', { name: 'Sluiten' }).click();
diff --git a/src/app/shared/layout/shell/shell.stories.ts b/src/app/shared/layout/shell/shell.stories.ts
index e8fce89..4bd4c29 100644
--- a/src/app/shared/layout/shell/shell.stories.ts
+++ b/src/app/shared/layout/shell/shell.stories.ts
@@ -2,16 +2,22 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { AccessStore } from '@shared/application/access.store';
+import { FeatureFlagStore } from '@shared/application/feature-flags.store';
import { ShellComponent } from './shell.component';
const meta: Meta = {
title: 'Design System/Templates/Shell',
component: ShellComponent,
- // The persistent header injects AccessStore (for its capability-gated admin links);
- // stub it so the story needs no HTTP/ApiClient. `can` false → no admin links.
+ // The persistent header injects AccessStore (for its capability-gated admin links) and
+ // FeatureFlagStore (WP-47, for the Inschrijven nav gate); stub both so the story needs no
+ // HTTP/ApiClient. `can` false → no admin links; `enabled` true → Inschrijven stays visible.
decorators: [
applicationConfig({
- providers: [provideRouter([]), { provide: AccessStore, useValue: { can: () => false } }],
+ providers: [
+ provideRouter([]),
+ { provide: AccessStore, useValue: { can: () => false } },
+ { provide: FeatureFlagStore, useValue: { enabled: () => true } },
+ ],
}),
],
};
diff --git a/src/app/shared/layout/site-header/site-header.stories.ts b/src/app/shared/layout/site-header/site-header.stories.ts
index cad2cd6..0f6a4f5 100644
--- a/src/app/shared/layout/site-header/site-header.stories.ts
+++ b/src/app/shared/layout/site-header/site-header.stories.ts
@@ -2,16 +2,19 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { AccessStore } from '@shared/application/access.store';
+import { FeatureFlagStore } from '@shared/application/feature-flags.store';
import { Capability } from '@shared/domain/capability';
import { SiteHeaderComponent } from './site-header.component';
-// The header injects AccessStore for the capability-gated admin links; stub it so the
-// story needs no HTTP/ApiClient. `can` decides which admin links appear.
+// The header injects AccessStore for the capability-gated admin links and FeatureFlagStore
+// (WP-47, for the Inschrijven nav gate); stub both so the story needs no HTTP/ApiClient.
+// `can` decides which admin links appear; `enabled` true keeps Inschrijven visible.
const withCaps = (caps: Capability[]) =>
applicationConfig({
providers: [
provideRouter([]),
{ provide: AccessStore, useValue: { can: (c: Capability) => caps.includes(c) } },
+ { provide: FeatureFlagStore, useValue: { enabled: () => true } },
],
});