diff --git a/deployments/api/alembic/versions/71aa7ef8150b_add_nor_source_priority.py b/deployments/api/alembic/versions/71aa7ef8150b_add_nor_source_priority.py
new file mode 100644
index 00000000..e53aa612
--- /dev/null
+++ b/deployments/api/alembic/versions/71aa7ef8150b_add_nor_source_priority.py
@@ -0,0 +1,49 @@
+"""add nor source priority
+
+Revision ID: 71aa7ef8150b
+Revises: 3a7e120d22d1
+Create Date: 2026-09-23 00:00:00.000000
+"""
+
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = "71aa7ef8150b"
+down_revision = "3a7e120d22d1"
+branch_labels = None
+depends_on = None
+
+_REV_SOURCE_PRIORITY = ("rmi", "wm", "ccr", "bc", "alb", "nor", "gem", "llm")
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ bind.execute(
+ sa.text("UPDATE og_field_source_priority SET priority = priority + 100")
+ )
+ existing = {
+ row[0]
+ for row in bind.execute(sa.text("SELECT source FROM og_field_source_priority"))
+ }
+ for priority, source in enumerate(_REV_SOURCE_PRIORITY, start=1):
+ if source in existing:
+ bind.execute(
+ sa.text(
+ "UPDATE og_field_source_priority SET priority = :priority "
+ "WHERE source = :source"
+ ).bindparams(priority=priority, source=source)
+ )
+ else:
+ bind.execute(
+ sa.text(
+ "INSERT INTO og_field_source_priority (source, priority) "
+ "VALUES (:source, :priority)"
+ ).bindparams(source=source, priority=priority)
+ )
+
+
+def downgrade() -> None:
+ raise RuntimeError(f"Irreversible migration: {revision}")
diff --git a/deployments/api/tests/routers/test_resources_unit.py b/deployments/api/tests/routers/test_resources_unit.py
index 408d5c04..069502e3 100644
--- a/deployments/api/tests/routers/test_resources_unit.py
+++ b/deployments/api/tests/routers/test_resources_unit.py
@@ -281,5 +281,5 @@ async def override_get_uow():
mock_repo.filter_options.assert_awaited_once()
call_kwargs = mock_repo.filter_options.call_args.kwargs
assert call_kwargs["licensed_sources"] == frozenset(
- {"rmi", "gem", "wm", "ccr", "alb", "bc", "llm"}
+ {"rmi", "gem", "wm", "ccr", "alb", "bc", "nor", "llm"}
)
diff --git a/deployments/api/tests/utils.py b/deployments/api/tests/utils.py
index ce1f42b9..b3356825 100644
--- a/deployments/api/tests/utils.py
+++ b/deployments/api/tests/utils.py
@@ -17,6 +17,7 @@
CCRSource,
GemSource,
LLMSource,
+ NORSource,
OGSISrcKey,
RMISource,
WoodMacSource,
@@ -117,6 +118,8 @@ def make_source(
return ALBSource(**kwargs)
case "bc":
return BCSource(**kwargs)
+ case "nor":
+ return NORSource(**kwargs)
def make_resource(
diff --git a/deployments/stitch-frontend/src/components/FieldCard.jsx b/deployments/stitch-frontend/src/components/FieldCard.jsx
index 74df0d4a..4cf6db99 100644
--- a/deployments/stitch-frontend/src/components/FieldCard.jsx
+++ b/deployments/stitch-frontend/src/components/FieldCard.jsx
@@ -7,7 +7,7 @@ import {
} from "../constants/sourceMeta";
// Used to display a single field value in a card, as seen in the ResourceDetailPage.
-// Pass `source` (one of "gem" | "wm" | "ccr" | "bc" | "alb" | "rmi" | "llm") to tint the left border by data source.
+// Pass `source` (one of "gem" | "wm" | "ccr" | "bc" | "alb" | "nor" | "rmi" | "llm") to tint the left border by data source.
// Pass `expandable` + `isOpen` + `onToggle` to make the value a toggle button; `children`
// (e.g. an "All sources" panel) render below the box while open. The card is presentational:
// the parent owns open state and any data fetching.
diff --git a/deployments/stitch-frontend/src/constants/sourceMeta.js b/deployments/stitch-frontend/src/constants/sourceMeta.js
index 4b76a0fb..45c90f53 100644
--- a/deployments/stitch-frontend/src/constants/sourceMeta.js
+++ b/deployments/stitch-frontend/src/constants/sourceMeta.js
@@ -1,4 +1,4 @@
-export const SOURCES = ["rmi", "wm", "ccr", "bc", "alb", "gem", "llm"];
+export const SOURCES = ["rmi", "wm", "ccr", "bc", "alb", "nor", "gem", "llm"];
/**
* Priority order for coalescing a value across sources, best first. Mirrors
@@ -6,7 +6,16 @@ export const SOURCES = ["rmi", "wm", "ccr", "bc", "alb", "gem", "llm"];
* packages/stitch-ogsi/src/stitch/ogsi/model, which is the single source of
* truth. Keep this list in sync with it until the constant is generated.
*/
-export const SOURCE_PRIORITY = ["rmi", "wm", "ccr", "bc", "alb", "gem", "llm"];
+export const SOURCE_PRIORITY = [
+ "rmi",
+ "wm",
+ "ccr",
+ "bc",
+ "alb",
+ "nor",
+ "gem",
+ "llm",
+];
export const SOURCE_COLORS = {
gem: "#45cfcc", // energy teal
@@ -14,6 +23,7 @@ export const SOURCE_COLORS = {
ccr: "#f2994a", // amber
alb: "#eb5757", // red
bc: "#27ae60", // green
+ nor: "#c14ea3", // magenta
rmi: "#ffcb00", // solar
llm: "#529cba", // RMI blue
};
@@ -25,6 +35,7 @@ export const SOURCE_LABELS = {
ccr: "C&C Reservoirs",
alb: "Alberta Energy Regulator",
bc: "BC Energy Regulator",
+ nor: "Norwegian Offshore Directorate",
rmi: "RMI",
};
diff --git a/deployments/stitch-frontend/src/pages/EtlPage.jsx b/deployments/stitch-frontend/src/pages/EtlPage.jsx
index 1d172ccb..434df1cd 100644
--- a/deployments/stitch-frontend/src/pages/EtlPage.jsx
+++ b/deployments/stitch-frontend/src/pages/EtlPage.jsx
@@ -57,11 +57,9 @@ const BC_FIELDS = [
},
];
-// No run parameters yet: the Alberta dataset hasn't been added to
-// stitch-etl-poc, so the real request-body shape is unknown. Leave empty (no
-// inputs, empty body) until the dataset lands and the fields can be defined to
-// match it.
+// No run parameters needed as of 2026-09-23 for ALB or NOR.
const ALB_FIELDS = [];
+const NOR_FIELDS = [];
const WOODMAC_FIELDS = [
{
@@ -342,6 +340,13 @@ export default function EtlPage() {
fields={ALB_FIELDS}
getToken={getToken}
/>
+
);
diff --git a/deployments/stitch-frontend/src/pages/EtlPage.test.jsx b/deployments/stitch-frontend/src/pages/EtlPage.test.jsx
index df64c879..4353ce2a 100644
--- a/deployments/stitch-frontend/src/pages/EtlPage.test.jsx
+++ b/deployments/stitch-frontend/src/pages/EtlPage.test.jsx
@@ -32,12 +32,13 @@ describe("EtlPage", () => {
expect(
screen.getByRole("heading", { name: "Alberta" }),
).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "Norway" })).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: "Start run" })).toHaveLength(
- 5,
+ 6,
);
expect(
screen.getAllByRole("button", { name: "Refresh status" }),
- ).toHaveLength(5);
+ ).toHaveLength(6);
});
it("starts a GEM run with an authenticated token and shows the returned state", async () => {
@@ -189,6 +190,43 @@ describe("EtlPage", () => {
);
});
+ it("starts a Norway run against the nor ETL endpoint", async () => {
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: true,
+ status: 202,
+ text: async () =>
+ JSON.stringify({
+ job_id: "job-nor",
+ state: "running",
+ started_at: "2026-06-11T10:00:00Z",
+ initiated_by: "Test User",
+ }),
+ });
+
+ renderWithQueryClient();
+
+ const norPanel = getPanel("Norway");
+ await userEvent.click(
+ within(norPanel).getByRole("button", { name: "Start run" }),
+ );
+
+ await waitFor(() => {
+ expect(within(norPanel).getAllByText("running").length).toBeGreaterThan(
+ 0,
+ );
+ });
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ "http://localhost:8100/api/v1/etl/nor/start",
+ expect.objectContaining({
+ method: "POST",
+ headers: expect.objectContaining({
+ Authorization: "Bearer test-access-token",
+ }),
+ }),
+ );
+ });
+
it("surfaces a friendly message when a run is already in progress (409)", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
diff --git a/packages/stitch-auth/src/stitch/auth/__init__.py b/packages/stitch-auth/src/stitch/auth/__init__.py
index 099036d8..9aba71b9 100644
--- a/packages/stitch-auth/src/stitch/auth/__init__.py
+++ b/packages/stitch-auth/src/stitch/auth/__init__.py
@@ -20,6 +20,7 @@
SOURCE_READ_CCR,
SOURCE_READ_GEM,
SOURCE_READ_LLM,
+ SOURCE_READ_NOR,
SOURCE_READ_PERMISSIONS,
SOURCE_READ_PREFIX,
SOURCE_READ_RMI,
@@ -49,6 +50,7 @@
"SOURCE_READ_CCR",
"SOURCE_READ_GEM",
"SOURCE_READ_LLM",
+ "SOURCE_READ_NOR",
"SOURCE_READ_PERMISSIONS",
"SOURCE_READ_PREFIX",
"SOURCE_READ_RMI",
diff --git a/packages/stitch-auth/src/stitch/auth/permissions.py b/packages/stitch-auth/src/stitch/auth/permissions.py
index c36afa3e..702595dd 100644
--- a/packages/stitch-auth/src/stitch/auth/permissions.py
+++ b/packages/stitch-auth/src/stitch/auth/permissions.py
@@ -23,6 +23,7 @@
SOURCE_READ_CCR: Permission = f"{SOURCE_READ_PREFIX}ccr"
SOURCE_READ_BC: Permission = f"{SOURCE_READ_PREFIX}bc"
SOURCE_READ_ALB: Permission = f"{SOURCE_READ_PREFIX}alb"
+SOURCE_READ_NOR: Permission = f"{SOURCE_READ_PREFIX}nor"
SOURCE_READ_PERMISSIONS: frozenset[Permission] = frozenset(
{
SOURCE_READ_RMI,
@@ -32,6 +33,7 @@
SOURCE_READ_CCR,
SOURCE_READ_BC,
SOURCE_READ_ALB,
+ SOURCE_READ_NOR,
}
)
SOURCE_WRITE: Permission = "source:write"
diff --git a/packages/stitch-auth/tests/test_permissions.py b/packages/stitch-auth/tests/test_permissions.py
index 882e7c48..69dc81da 100644
--- a/packages/stitch-auth/tests/test_permissions.py
+++ b/packages/stitch-auth/tests/test_permissions.py
@@ -12,6 +12,7 @@
SOURCE_READ_BC,
SOURCE_READ_CCR,
SOURCE_READ_GEM,
+ SOURCE_READ_NOR,
SOURCE_READ_PERMISSIONS,
SOURCE_READ_RMI,
SOURCE_WRITE,
@@ -77,6 +78,19 @@ def test_source_read_sources_resolves_alb():
) == frozenset({"alb"})
+def test_nor_source_read_permission_is_registered():
+ assert SOURCE_READ_NOR == "source:read:nor"
+ assert SOURCE_READ_NOR in SOURCE_READ_PERMISSIONS
+ assert SOURCE_READ_NOR in ALL_PERMISSIONS
+
+
+def test_source_read_sources_resolves_nor():
+ assert source_read_sources(
+ [SOURCE_READ_NOR],
+ valid_sources={"nor"},
+ ) == frozenset({"nor"})
+
+
def test_missing_permissions_uses_exact_matching():
granted = {RESOURCE_READ, SOURCE_READ_RMI}
diff --git a/packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py b/packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py
index 415220cc..80958072 100644
--- a/packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py
+++ b/packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py
@@ -16,6 +16,7 @@
GEMSrcKey,
LLMSrcKey,
LocationType,
+ NORSrcKey,
OGSISrcKey,
RMISrcKey,
WMSrcKey,
@@ -42,6 +43,8 @@
"ALBSourceView",
"BCSource",
"BCSourceView",
+ "NORSource",
+ "NORSourceView",
"SourceRecord",
"LocationType",
"OGFieldName",
@@ -59,6 +62,7 @@
CCR_SRC: Final[CCRSrcKey] = "ccr"
ALB_SRC: Final[ALBSrcKey] = "alb"
BC_SRC: Final[BCSrcKey] = "bc"
+NOR_SRC: Final[NORSrcKey] = "nor"
# Canonical source coalescing priority (highest first). Single source of truth
# for the coalescer, the query-param default, and the DB seed.
@@ -68,6 +72,7 @@
CCR_SRC,
BC_SRC,
ALB_SRC,
+ NOR_SRC,
GEM_SRC,
LLM_SRC,
)
@@ -105,6 +110,14 @@ class BCSourceView(SourceView[int, BCSrcKey], OilGasFieldBase):
source: BCSrcKey = BC_SRC
+class NORSource(Source[int, NORSrcKey], OilGasFieldBase):
+ source: NORSrcKey = NOR_SRC
+
+
+class NORSourceView(SourceView[int, NORSrcKey], OilGasFieldBase):
+ source: NORSrcKey = NOR_SRC
+
+
class WoodMacSource(Source[int, WMSrcKey], OilGasFieldBase):
source: WMSrcKey = WM_SRC
@@ -136,7 +149,8 @@ class LLMSourceView(SourceView[int, LLMSrcKey], OilGasFieldBase):
| LLMSource
| CCRSource
| ALBSource
- | BCSource,
+ | BCSource
+ | NORSource,
Field(discriminator="source"),
]
@@ -147,7 +161,8 @@ class LLMSourceView(SourceView[int, LLMSrcKey], OilGasFieldBase):
| LLMSourceView
| CCRSourceView
| ALBSourceView
- | BCSourceView,
+ | BCSourceView
+ | NORSourceView,
Field(discriminator="source"),
]
diff --git a/packages/stitch-ogsi/src/stitch/ogsi/model/types.py b/packages/stitch-ogsi/src/stitch/ogsi/model/types.py
index 4ef5200f..8031145c 100644
--- a/packages/stitch-ogsi/src/stitch/ogsi/model/types.py
+++ b/packages/stitch-ogsi/src/stitch/ogsi/model/types.py
@@ -7,9 +7,10 @@
CCRSrcKey = Literal["ccr"]
BCSrcKey = Literal["bc"]
ALBSrcKey = Literal["alb"]
+NORSrcKey = Literal["nor"]
-OGSISrcKey = Literal["gem", "wm", "rmi", "llm", "ccr", "alb", "bc"]
+OGSISrcKey = Literal["gem", "wm", "rmi", "llm", "ccr", "alb", "bc", "nor"]
LocationType = Literal["Onshore", "Offshore", "Unknown"]
diff --git a/packages/stitch-ogsi/tests/test_og_field.py b/packages/stitch-ogsi/tests/test_og_field.py
index 52f4b12b..9a75de6c 100644
--- a/packages/stitch-ogsi/tests/test_og_field.py
+++ b/packages/stitch-ogsi/tests/test_og_field.py
@@ -16,6 +16,7 @@
BCSource,
CCRSource,
GemSource,
+ NORSource,
OGFieldDetailView,
OGFieldResource,
OGFieldSource,
@@ -75,6 +76,14 @@ def test_alb_source_from_json(self):
assert obj.source == "alb"
assert obj.name == "Test Field"
+ def test_nor_source_from_json(self):
+ obj = _source_adapter.validate_json(
+ '{"source": "nor", "name": "Test Field", "country": "NOR", "source_record": {"observed_at": "2026-01-01T00:00:00Z", "producer": "test", "payload": {"kind": "fixture"}}}'
+ )
+ assert isinstance(obj, NORSource)
+ assert obj.source == "nor"
+ assert obj.name == "Test Field"
+
def test_invalid_source_key_rejected(self):
with pytest.raises(ValidationError):
_source_adapter.validate_json(